diff --git a/Gemfile.lock b/Gemfile.lock index 0e48985b0..ea92feb39 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,8 +1,8 @@ GIT remote: https://github.com/basecamp/lexxy - revision: 7c197c0afc7095c89df9cb6e24484df9e7212ac8 + revision: 4f0fc4d5773bc6892de70f175440c259974c12a7 specs: - lexxy (0.1.24.beta) + lexxy (0.7.0.beta) rails (>= 8.0.2) GIT @@ -147,7 +147,7 @@ GEM benchmark (0.5.0) bigdecimal (4.0.1) bindex (0.8.1) - bootsnap (1.20.1) + bootsnap (1.21.1) msgpack (~> 1.2) brakeman (7.1.2) racc @@ -449,7 +449,7 @@ GEM timeout (0.6.0) trilogy (2.9.0) tsort (0.2.0) - turbo-rails (2.0.20) + turbo-rails (2.0.21) actionpack (>= 7.1.0) railties (>= 7.1.0) tzinfo (2.0.6) diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index f340b4ae4..6dcb7b9ee 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -21,9 +21,9 @@ GIT GIT remote: https://github.com/basecamp/lexxy - revision: 7c197c0afc7095c89df9cb6e24484df9e7212ac8 + revision: 4f0fc4d5773bc6892de70f175440c259974c12a7 specs: - lexxy (0.1.24.beta) + lexxy (0.7.0.beta) rails (>= 8.0.2) GIT @@ -225,7 +225,7 @@ GEM benchmark (0.5.0) bigdecimal (4.0.1) bindex (0.8.1) - bootsnap (1.20.1) + bootsnap (1.21.1) msgpack (~> 1.2) brakeman (7.1.2) racc @@ -568,7 +568,7 @@ GEM timeout (0.6.0) trilogy (2.9.0) tsort (0.2.0) - turbo-rails (2.0.20) + turbo-rails (2.0.21) actionpack (>= 7.1.0) railties (>= 7.1.0) tzinfo (2.0.6) diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css index 2c0c67ef9..3409b83de 100644 --- a/app/assets/stylesheets/card-columns.css +++ b/app/assets/stylesheets/card-columns.css @@ -378,6 +378,7 @@ } .cards__menu { + position: relative; z-index: var(--z-popup); } @@ -585,17 +586,28 @@ } } - .card:has(.card__background img:not([src=""])) { + &:has(.card__background img:not([src=""])) { .card__content, .card__meta, + .card__comments, .card__column-name:not(.card__column-name--current) { opacity: 0; transition: opacity 0.2s ease-in-out; + } - @media (any-hover: hover) { - .card:hover & { + @media (any-hover: hover) { + &:hover { + .card__content, + .card__footer, + .card__comments, + .card__column-name:not(.card__column-name--current) { opacity: 1; } + + .card__background img { + filter: blur(3px) brightness(1.2); + opacity: 0.2; + } } } } @@ -604,13 +616,6 @@ inset-inline-start: 100%; translate: -90% -40%; } - - @media (any-hover: hover) { - .card:has(.card__background img:not([src=""])):hover .card__background img:not([src=""]) { - filter: blur(3px) brightness(1.2); - opacity: 0.2; - } - } } /* Considering diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css index 8d1340c23..d3b671b9a 100644 --- a/app/assets/stylesheets/card-perma.css +++ b/app/assets/stylesheets/card-perma.css @@ -119,17 +119,6 @@ } } - .board-picker__button { - --btn-border-size: 0; - - font-size: 0.5em; - margin-inline-start: 1ch; - - + .panel { - --panel-size: 18ch; - } - } - .bubble { --bubble-number-max: 42px; --bubble-size: 6rem; @@ -292,10 +281,7 @@ /* Overlap the card BG by half the button height */ &:has(.btn) { - &, - ~ .card-perma__closure-message { - translate: 0 calc(-1 * var(--half-btn-height)); - } + translate: 0 calc(-1 * var(--half-btn-height)); } form { @@ -308,12 +294,6 @@ --btn-color: var(--color-ink-inverted); } - .btn--plain { - --btn-color: var(--card-color); - - text-decoration: underline; - } - .btn--reversed { --btn-background: var(--color-canvas); --btn-color: var(--card-color); @@ -341,11 +321,25 @@ .card-perma__closure-message { color: var(--card-color); grid-area: closure-message; - margin-block-start: 0.5ch; + margin-block: var(--block-space) var(--block-space-double); padding-inline: 1ch; - form { - display: inline; + .btn--plain { + --btn-color: var(--card-color); + + text-decoration: underline; + } + + @media (max-width: 799px) { + margin-block: var(--block-space-half); + translate: 0 calc(-0.5 * var(--half-btn-height)); + } + + @media (min-width: 800px) { + .card-perma__notch--bottom:has(.btn) ~ & { + margin-block: var(--block-space-half) var(--block-space); + translate: 0 calc(-0.5 * var(--half-btn-height)); + } } } } diff --git a/app/assets/stylesheets/cards.css b/app/assets/stylesheets/cards.css index 3fb0a7f4d..3aec6e3b0 100644 --- a/app/assets/stylesheets/cards.css +++ b/app/assets/stylesheets/cards.css @@ -71,21 +71,19 @@ min-inline-size: 0; padding-block: 0.25lh; padding-inline: var(--card-padding-inline) 1ch; + position: relative; + transition: background-color 100ms ease-out; - &:has(.board-picker__button) { - cursor: pointer; - transition: background-color 100ms ease-out; - + &:has(.btn) { @media (any-hover: hover) { &:hover { background-color: color-mix(in srgb, var(--card-color) 90%, var(--color-ink)); } } + } - &:has(.btn:focus-visible) { - outline: var(--focus-ring-size) solid var(--focus-ring-color); - outline-offset: var(--focus-ring-offset); - } + dialog { + inset-block-start: 100%; } } @@ -99,9 +97,11 @@ } .card__board-name { + align-items: center; border-inline-start: 1px solid color-mix(in hsl, transparent 75%, currentColor); color: currentColor; display: flex; + gap: 0.25ch; margin-inline-start: var(--card-header-space); max-inline-size: 100%; min-inline-size: 0; @@ -109,14 +109,59 @@ text-transform: uppercase; } + .card__board-picker-button { + inset: 0; + position: absolute; + } + .card__tags { --btn-color: var(--card-color); align-items: center; + align-self: stretch; color: var(--card-text-color); display: flex; - gap: var(--card-header-space); + gap: 0.5ch; min-inline-size: 0; + + [data-controller="dialog"] { + align-items: center; + align-self: stretch; + display: flex; + position: relative; + } + + .popup { + --panel-size: 18ch; + + inset-block-start: 100%; + } + } + + .card__tag-picker { + --panel-border-radius: 2em; + --panel-padding: 0.5em 0.7em; + --panel-size: max-content; + + inline-size: auto !important; + inset: 0 auto auto 0; + max-inline-size: var(--panel-size) !important; + position: absolute; + z-index: 2; + + &[open] { + display: flex; + } + + .input { + --input-padding: 0.2em 0.5em; + + inline-size: 18ch; + } + } + + .card__tag-picker-button { + font-size: 0.6em; } .card__tag { diff --git a/app/assets/stylesheets/lexxy.css b/app/assets/stylesheets/lexxy.css index 21ee18301..c35f14fee 100644 --- a/app/assets/stylesheets/lexxy.css +++ b/app/assets/stylesheets/lexxy.css @@ -7,7 +7,8 @@ position: relative; overflow: visible; - figure.node--selected { + figure.node--selected, + div.node--selected { &:has(img) { img { outline: var(--focus-ring-size) solid var(--focus-ring-color); @@ -22,6 +23,31 @@ } } + .lexxy-content__table-wrapper { + margin: 0; + margin-block: 1ch; + overflow-x: auto; + } + + table { + th, td { + &.table-cell--selected { + background-color: var(--color-selected-light); + } + + &.lexxy-content__table-cell--selected { + background-color: var(--color-selected); + border-color: var(--color-selected-dark); + } + } + + &.lexxy-content__table--selection { + ::selection { + background: transparent; + } + } + } + /* Lexical uses the `lexxy-editor--empty` class even if you have a list * started but haven't added other characters. Here, we hide the placeholder * when you click the List button in the toolbar. */ @@ -101,6 +127,11 @@ /* Toolbar /* ------------------------------------------------------------------------ */ + /* TODO: Temporary - hide table button while we work on the styles */ + button[name="table"] { + display: none; + } + lexxy-toolbar { --lexxy-toolbar-icon-size: 1em; @@ -115,7 +146,7 @@ position: relative; position: sticky; inset-block-start: 0; - z-index: 1; + z-index: 2; } .lexxy-editor__toolbar-button { @@ -166,7 +197,7 @@ inset-inline-end: 0; padding: 4px; position: absolute; - z-index: 1; + z-index: 2; } .lexxy-editor__toolbar-spacer { @@ -188,7 +219,7 @@ font-size: var(--text-small); padding: var(--lexxy-dropdown-padding); position: absolute; - z-index: 1; + z-index: 2; button { block-size: var(--lexxy-dropdown-btn-size); @@ -288,7 +319,130 @@ } } - /* Prompt ,enu (@mentions, etc.) + .lexxy-table-handle-buttons { + --button-size: 2.5em; + + color: var(--color-ink-inverted); + display: none; + flex-direction: row; + font-size: var(--text-x-small); + gap: 0.25ch; + line-height: 1; + position: absolute; + transform: translate(-50%, -120%); + z-index: 1; + + &:has([open]) { + z-index: 4; + } + + .lexxy-table-control { + align-items: center; + background-color: var(--color-ink); + border-radius: 0.75ch; + display: flex; + flex-direction: row; + gap: 1ch; + padding: 2px; + white-space: nowrap; + + button, + summary { + aspect-ratio: 1 / 1; + align-items: center; + background-color: transparent; + border-radius: 0.5ch; + border: 0; + color: var(--color-ink-inverted); + cursor: pointer; + display: flex; + font-size: inherit; + font-weight: 700; + justify-content: center; + line-height: 1; + list-style: none; + min-block-size: var(--button-size); + min-inline-size: var(--button-size); + padding: 0; + user-select: none; + -webkit-user-select: none; + + &:hover { + background-color: var(--color-ink-darker); + } + + &:focus, + &:focus-visible { + background-color: var(--color-ink-darker); + outline: var(--focus-ring-size) solid var(--focus-ring-color); + outline-offset: var(--focus-ring-offset); + } + + svg { + block-size: 1em; + inline-size: 1em; + fill: currentColor; + } + + span { + display: none; + } + } + } + + .lexxy-table-control__more-menu { + gap: 0; + padding: 2px; + position: relative; + + summary { + &::-webkit-details-marker { + display: none; + } + } + + .lexxy-table-control__more-menu-details { + display: flex; + flex-direction: column; + gap: 0.25ch; + inset-block-start: 105%; + inset-inline-end: 0; + padding: 0; + position: absolute; + + .lexxy-table-control__more-menu-section { + background: var(--color-ink); + border-radius: 0.75ch; + display: flex; + flex-direction: column; + padding: 2px; + } + + button { + aspect-ratio: unset; + align-items: center; + flex-direction: row; + font-weight: normal; + gap: 1ch; + justify-content: flex-start; + padding: 0.5ch 2ch; + padding-inline-start: 1ch; + white-space: nowrap; + + svg { + block-size: 1.3em; + inline-size: 1.3em; + } + + span { + display: inline-block; + } + } + } + } + } + + /* Prompt menu (@mentions, etc.) /* ------------------------------------------------------------------------ */ .lexxy-prompt-menu { diff --git a/app/assets/stylesheets/native.css b/app/assets/stylesheets/native.css index 17129a915..6e2e12981 100644 --- a/app/assets/stylesheets/native.css +++ b/app/assets/stylesheets/native.css @@ -1,5 +1,7 @@ @layer native { [data-platform~=native] { + --footer-height: 0; + -webkit-tap-highlight-color: transparent; .hide-on-native { @@ -27,11 +29,22 @@ } } + /* Card columns + /* ------------------------------------------------------------------------ */ + + .board-tools.card { + padding-block-start: 0; + } + /* Card perma /* ------------------------------------------------------------------------ */ .card-perma { margin-block-start: 0; + + &:not(:has(.card-perma__notch-new-card-buttons)) .card-perma__bg { + padding-block: clamp(0.25rem, 2vw, var(--padding-block)); + } } .card-perma__bg { @@ -39,6 +52,11 @@ border-start-end-radius: calc(0.2em + clamp(0.25rem, 2vw, var(--padding-block))); } + .card-perma__closure-message { + margin-block: var(--block-space); + translate: unset; + } + /* Search /* ------------------------------------------------------------------------ */ @@ -47,3 +65,25 @@ } } } + +[data-bridge-components~=form] { + [data-controller~=bridge--form] { + [data-bridge--form-target~=submit] { + display: none; + } + } +} + +[data-bridge-components~=overflow-menu] { + [data-controller~=bridge--overflow-menu] { + [data-bridge--overflow-menu-target~=item] { + display: none; + } + } +} + +[data-bridge-components~=buttons] { + [data-bridge--buttons-target~=button] { + display: none; + } +} diff --git a/app/assets/stylesheets/popup.css b/app/assets/stylesheets/popup.css index 02ae8969e..aa0b8c1da 100644 --- a/app/assets/stylesheets/popup.css +++ b/app/assets/stylesheets/popup.css @@ -21,11 +21,16 @@ display: var(--popup-display); } + /* The .pop-up--align- classes are used for initial alignment. + * The orient JS helper layers on the .orient- to avoid running + * off the edge of the screen and will override .popup--align */ + &:where(.popup--align-left), &.orient-left { inset-inline: auto 0; transform: translateX(0); } + &:where(.popup--align-right), &.orient-right { inset-inline: 0 auto; transform: translateX(0); @@ -48,6 +53,10 @@ .popup__title { font-weight: 800; white-space: nowrap; + + &[tabindex="-1"]:focus-visible { + outline: unset; + } } /* Hide lists when all the items within are hidden */ @@ -123,6 +132,10 @@ } } + &:has(.popup__btn[disabled]) { + pointer-events: none; + } + .checked { display: none; } @@ -155,7 +168,7 @@ max-inline-size: 100%; padding: var(--inline-space-half) var(--popup-item-padding-inline); text-align: start; - + &:focus-visible { z-index: 1; } diff --git a/app/assets/stylesheets/print.css b/app/assets/stylesheets/print.css index 5ed9c3fdf..cd55b5196 100644 --- a/app/assets/stylesheets/print.css +++ b/app/assets/stylesheets/print.css @@ -164,8 +164,8 @@ .comments__subscribers, .card__meta-avatars--assignees > div > div:last-child, .steps > .step:last-child, - div:has(> .tag-picker__button), - div:has(> .board-picker__button), + .card__board-name .icon, + div:has(> .card__tag-picker-button), .delete-card, .header--card .header__title { display: none; diff --git a/app/assets/stylesheets/rich-text-content.css b/app/assets/stylesheets/rich-text-content.css index 88e4d2ae2..bf5328478 100644 --- a/app/assets/stylesheets/rich-text-content.css +++ b/app/assets/stylesheets/rich-text-content.css @@ -209,6 +209,45 @@ } } } + + /* Tables */ + .lexxy-content__table-wrapper { + margin: 0; + margin-block: 1ch; + overflow-x: auto; + } + + table { + border-collapse: collapse; + border-spacing: 0; + inline-size: calc(100% - 0.5ch); + margin: 0.25ch; + + th, + td { + border: 1px solid var(--color-ink-light); + padding: 0.5ch 1ch; + text-align: start; + word-break: normal; + + *:last-child { + margin-block-end: 0; + } + + *:not(pre, code) { + word-break: normal; + } + } + + th, + .lexxy-content__table-cell--header { + background: var(--color-ink-lightest); + } + + td { + background: var(--color-canvas); + } + } } /* Attachments diff --git a/app/assets/stylesheets/tags.css b/app/assets/stylesheets/tags.css deleted file mode 100644 index 90d51e011..000000000 --- a/app/assets/stylesheets/tags.css +++ /dev/null @@ -1,33 +0,0 @@ -@layer components { - .tag-picker { - --panel-border-radius: 2em; - --panel-padding: 0.5em 0.7em; - --panel-size: max-content; - - inline-size: auto !important; - inset: 0 auto auto 0; - max-inline-size: var(--panel-size) !important; - position: absolute; - z-index: 2; - - &[open] { - display: flex; - } - - .input { - --input-padding: 0.2em 0.5em; - - inline-size: 18ch; - } - } - - .tag-picker__button { - font-size: 0.6em; - margin-block-start: 0.1em; - margin-inline-start: 0.2em; - - + .panel { - --panel-size: 18ch; - } - } -} diff --git a/app/controllers/cards/self_assignments_controller.rb b/app/controllers/cards/self_assignments_controller.rb new file mode 100644 index 000000000..a38c99cf0 --- /dev/null +++ b/app/controllers/cards/self_assignments_controller.rb @@ -0,0 +1,17 @@ +class Cards::SelfAssignmentsController < ApplicationController + include CardScoped + + def create + if @card.toggle_assignment(Current.user) + respond_to do |format| + format.turbo_stream { render "cards/assignments/create" } + format.json { head :no_content } + end + else + respond_to do |format| + format.turbo_stream { render "cards/assignments/create" } + format.json { head :unprocessable_entity } + end + end + end +end diff --git a/app/controllers/users/avatars_controller.rb b/app/controllers/users/avatars_controller.rb index 420dbe5b6..55d8f06dd 100644 --- a/app/controllers/users/avatars_controller.rb +++ b/app/controllers/users/avatars_controller.rb @@ -8,7 +8,7 @@ class Users::AvatarsController < ApplicationController if @user.system? redirect_to view_context.image_path("system_user.png") elsif @user.avatar.attached? - redirect_to rails_blob_url(@user.avatar_thumbnail, disposition: "inline") + redirect_to rails_blob_path(@user.avatar_thumbnail, disposition: "inline") elsif stale? @user, cache_control: cache_control render_initials end diff --git a/app/helpers/accesses_helper.rb b/app/helpers/accesses_helper.rb index 1ace03574..c5a4ae1c6 100644 --- a/app/helpers/accesses_helper.rb +++ b/app/helpers/accesses_helper.rb @@ -48,7 +48,8 @@ module AccessesHelper params: { show_watchers: show_watchers, involvement: next_involvement(access.involvement), icon_only: icon_only }, aria: { labelledby: dom_id(board, :involvement_label) }, title: (label_text if icon_only), - class: class_names("btn", { "btn--reversed": access.watching? && icon_only })) do + class: class_names("btn", { "btn--reversed": access.watching? && icon_only }), + data: !icon_only && { bridge__overflow_menu_target: "item", bridge_title: label_text }) do icon_tag("notification-bell-#{icon_only ? 'reverse-' : nil}#{access.involvement.dasherize}") + tag.span(label_text, class: class_names("txt-nowrap txt-uppercase", "for-screen-reader": icon_only), id: dom_id(board, :involvement_label)) end diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb index 7c064a650..8ae4346ac 100644 --- a/app/helpers/avatars_helper.rb +++ b/app/helpers/avatars_helper.rb @@ -42,6 +42,6 @@ module AvatarsHelper end def avatar_image_tag(user, **options) - image_tag user_avatar_url(user, script_name: user.account.slug), aria: { hidden: "true" }, size: 48, title: user.name, **options + image_tag user_avatar_path(user, script_name: user.account.slug), aria: { hidden: "true" }, size: 48, title: user.name, **options end end diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb index 4ed22dc03..5f12b9259 100644 --- a/app/helpers/boards_helper.rb +++ b/app/helpers/boards_helper.rb @@ -4,7 +4,8 @@ module BoardsHelper end def link_to_edit_board(board) - link_to edit_board_path(board), class: "btn btn--circle-mobile", data: { controller: "tooltip" } do + link_to edit_board_path(board), class: "btn btn--circle-mobile", + data: { controller: "tooltip", bridge__overflow_menu_target: "item", bridge_title: "Board settings" } do icon_tag("settings") + tag.span("Settings for #{board.name}", class: "for-screen-reader") end end diff --git a/app/helpers/bridge_helper.rb b/app/helpers/bridge_helper.rb new file mode 100644 index 000000000..5fb4508b6 --- /dev/null +++ b/app/helpers/bridge_helper.rb @@ -0,0 +1,5 @@ +module BridgeHelper + def bridge_icon(name) + asset_url("#{name}.svg") + end +end diff --git a/app/helpers/entropy_helper.rb b/app/helpers/entropy_helper.rb index f1a50bbe9..4329dab88 100644 --- a/app/helpers/entropy_helper.rb +++ b/app/helpers/entropy_helper.rb @@ -16,6 +16,7 @@ module EntropyHelper { stalledAfterDays: card.entropy.days_before_reminder, lastActivitySpikeAt: card.last_activity_spike_at.iso8601, + updatedAt: card.updated_at.iso8601, action: "Stalled" } end diff --git a/app/helpers/filters_helper.rb b/app/helpers/filters_helper.rb index 6c3f01725..a0529b11a 100644 --- a/app/helpers/filters_helper.rb +++ b/app/helpers/filters_helper.rb @@ -40,6 +40,10 @@ module FiltersHelper }, &block end + def filter_title(title) + tag.strong title, class: "popup__title pad-inline-half", tabindex: "-1", data: { dialog_target: "focusTouch" } + end + def collapsible_nav_section(title, **properties, &block) tag.details class: "nav__section popup__section", data: { action: "toggle->nav-section-expander#toggle", nav_section_expander_target: "section", nav_section_expander_key_value: title.parameterize }, open: true, **properties do concat(tag.summary(class: "popup__section-title") do diff --git a/app/helpers/forms_helper.rb b/app/helpers/forms_helper.rb index 095655e3b..2902f809d 100644 --- a/app/helpers/forms_helper.rb +++ b/app/helpers/forms_helper.rb @@ -9,4 +9,23 @@ module FormsHelper form_with(**attributes, data: data) { } end end + + def bridged_form_with(**attributes, &) + data = attributes.delete(:data) || {} + controllers = [ data[:controller], "bridge--form" ].compact.join(" ").strip + actions = [ + data[:action], + "turbo:submit-start->bridge--form#submitStart", + "turbo:submit-end->bridge--form#submitEnd" + ].compact.join(" ").strip + + data[:controller] = controllers + data[:action] = actions + + if block_given? + form_with **attributes, data: data, & + else + form_with(**attributes, data: data) { } + end + end end diff --git a/app/helpers/webhooks_helper.rb b/app/helpers/webhooks_helper.rb index dea0ea9bd..246859539 100644 --- a/app/helpers/webhooks_helper.rb +++ b/app/helpers/webhooks_helper.rb @@ -25,7 +25,7 @@ module WebhooksHelper def link_to_webhooks(board, &) link_to board_webhooks_path(board_id: board), class: [ "btn btn--circle-mobile", { "btn--reversed": board.webhooks.any? } ], - data: { controller: "tooltip" } do + data: { controller: "tooltip", bridge__overflow_menu_target: "item", bridge_title: "Webhooks" } do icon_tag("world") + tag.span("Webhooks", class: "for-screen-reader") end end diff --git a/app/javascript/application.js b/app/javascript/application.js index bdafb95b0..c43a45eaa 100644 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -1,8 +1,8 @@ // Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails import "@hotwired/turbo-rails" +import "@hotwired/hotwire-native-bridge" import "initializers" import "controllers" import "lexxy" import "@rails/actiontext" - diff --git a/app/javascript/controllers/bridge/buttons_controller.js b/app/javascript/controllers/bridge/buttons_controller.js new file mode 100644 index 000000000..47d09a158 --- /dev/null +++ b/app/javascript/controllers/bridge/buttons_controller.js @@ -0,0 +1,43 @@ +import { BridgeComponent } from "@hotwired/hotwire-native-bridge" +import { BridgeElement } from "@hotwired/hotwire-native-bridge" + +export default class extends BridgeComponent { + static component = "buttons" + static targets = [ "button" ] + + buttonTargetConnected() { + this.notifyBridgeOfConnect() + } + + buttonTargetDisconnected() { + if (!this.#isControllerTearingDown()) { + this.notifyBridgeOfConnect() + } + } + + notifyBridgeOfConnect() { + const buttons = this.#enabledButtonTargets + .map((target, index) => { + const element = new BridgeElement(target) + return { ...element.getButton(), index } + }) + + this.send("connect", { buttons }, message => { + this.#clickButton(message) + }) + } + + #clickButton(message) { + const selectedIndex = message.data.selectedIndex + this.#enabledButtonTargets[selectedIndex].click() + } + + get #enabledButtonTargets() { + return this.buttonTargets + .filter(target => !target.closest("[data-bridge-disabled]")) + } + + #isControllerTearingDown() { + return !document.body.contains(this.element) + } +} diff --git a/app/javascript/controllers/bridge/form_controller.js b/app/javascript/controllers/bridge/form_controller.js new file mode 100644 index 000000000..a3c780e62 --- /dev/null +++ b/app/javascript/controllers/bridge/form_controller.js @@ -0,0 +1,62 @@ +import { BridgeComponent } from "@hotwired/hotwire-native-bridge" +import { BridgeElement } from "@hotwired/hotwire-native-bridge" + +export default class extends BridgeComponent { + static component = "form" + static targets = [ "submit", "cancel" ] + static values = { submitTitle: String } + + submitTargetConnected() { + this.notifyBridgeOfConnect() + this.#observeSubmitTarget() + } + + submitTargetDisconnected() { + this.notifyBridgeOfDisonnect() + this.submitObserver?.disconnect() + } + + notifyBridgeOfConnect() { + const submitElement = new BridgeElement(this.submitTarget) + const cancelElement = this.hasCancelTarget ? new BridgeElement(this.cancelTarget) : null + + const submitButton = { title: submitElement.title } + const cancelButton = cancelElement ? { title: cancelElement.title } : null + + this.send("connect", { submitButton, cancelButton }, message => this.receive(message)) + } + + receive(message) { + switch (message.event) { + case "submit": + this.submitTarget.click() + break + case "cancel": + this.cancelTarget.click() + break + } + } + + notifyBridgeOfDisonnect() { + this.send("disconnect") + } + + submitStart() { + this.send("submitStart") + } + + submitEnd() { + this.send("submitEnd") + } + + #observeSubmitTarget() { + this.submitObserver = new MutationObserver(() => { + this.send(this.submitTarget.disabled ? "submitDisabled" : "submitEnabled") + }) + + this.submitObserver.observe(this.submitTarget, { + attributes: true, + attributeFilter: [ "disabled" ] + }) + } +} diff --git a/app/javascript/controllers/bridge/insets_controller.js b/app/javascript/controllers/bridge/insets_controller.js new file mode 100644 index 000000000..1cef15690 --- /dev/null +++ b/app/javascript/controllers/bridge/insets_controller.js @@ -0,0 +1,31 @@ +import { BridgeComponent } from "@hotwired/hotwire-native-bridge" + +// Bridge component to control custom safe-area insets from native apps. +// Sets CSS variables --injected-safe-inset-(top|right|bottom|left). +export default class extends BridgeComponent { + static component = "insets" + + connect() { + super.connect() + this.notifyBridgeOfConnect() + } + + disconnect() { + super.disconnect() + this.send("disconnect") + } + + notifyBridgeOfConnect() { + this.send("connect", {}, message => { + this.#setInsets(message.data) + }) + } + + #setInsets({ top, right, bottom, left }) { + const root = document.documentElement.style + root.setProperty("--injected-safe-inset-top", `${top}px`) + root.setProperty("--injected-safe-inset-right", `${right}px`) + root.setProperty("--injected-safe-inset-bottom", `${bottom}px`) + root.setProperty("--injected-safe-inset-left", `${left}px`) + } +} diff --git a/app/javascript/controllers/bridge/overflow_menu_controller.js b/app/javascript/controllers/bridge/overflow_menu_controller.js new file mode 100644 index 000000000..ba2045dc6 --- /dev/null +++ b/app/javascript/controllers/bridge/overflow_menu_controller.js @@ -0,0 +1,43 @@ +import { BridgeComponent } from "@hotwired/hotwire-native-bridge" +import { BridgeElement } from "@hotwired/hotwire-native-bridge" + +export default class extends BridgeComponent { + static component = "overflow-menu" + static targets = [ "item" ] + + itemTargetConnected() { + this.notifyBridgeOfConnect() + } + + itemTargetDisconnected() { + if (!this.#isControllerTearingDown) { + this.notifyBridgeOfConnect() + } + } + + notifyBridgeOfConnect() { + const items = this.#enabledItemTargets + .map((target, index) => { + const element = new BridgeElement(target) + return { title: element.title, index } + }) + + this.send("connect", { items }, message => { + this.#clickItem(message) + }) + } + + #clickItem(message) { + const selectedIndex = message.data.selectedIndex + this.#enabledItemTargets[selectedIndex].click() + } + + get #enabledItemTargets() { + return this.itemTargets + .filter(target => !target.closest("[data-bridge-disabled]")) + } + + #isControllerTearingDown() { + return !document.body.contains(this.element) + } +} diff --git a/app/javascript/controllers/bridge/text_size_controller.js b/app/javascript/controllers/bridge/text_size_controller.js new file mode 100644 index 000000000..a464b8d45 --- /dev/null +++ b/app/javascript/controllers/bridge/text_size_controller.js @@ -0,0 +1,25 @@ +import { BridgeComponent } from "@hotwired/hotwire-native-bridge" + +export default class extends BridgeComponent { + static component = "text-size" + + connect() { + super.connect() + this.notifyBridgeOfConnect() + } + + disconnect() { + super.disconnect() + this.send("disconnect") + } + + notifyBridgeOfConnect() { + this.send("connect", {}, message => { + this.#setTextSize(message.data) + }) + } + + #setTextSize(data) { + document.documentElement.dataset.textSize = data.textSize + } +} diff --git a/app/javascript/controllers/bridge/title_controller.js b/app/javascript/controllers/bridge/title_controller.js new file mode 100644 index 000000000..f2226be35 --- /dev/null +++ b/app/javascript/controllers/bridge/title_controller.js @@ -0,0 +1,63 @@ +import { BridgeComponent } from "@hotwired/hotwire-native-bridge" +import { viewport } from "helpers/bridge/viewport_helpers" +import { nextFrame } from "helpers/timing_helpers" + +export default class extends BridgeComponent { + static component = "title" + static targets = [ "header" ] + static values = { title: String } + + async connect() { + super.connect() + await nextFrame() + this.#startObserver() + window.addEventListener("resize", this.#windowResized) + } + + disconnect() { + super.disconnect() + this.#stopObserver() + window.removeEventListener("resize", this.#windowResized) + } + + notifyBridgeOfVisibilityChange(visible) { + this.send("visibility", { title: this.#title, elementVisible: visible }) + } + + // Intersection Observer + + #startObserver() { + if (!this.hasHeaderTarget) return + + this.observer = new IntersectionObserver(([ entry ]) => + this.notifyBridgeOfVisibilityChange(entry.isIntersecting), + { rootMargin: `-${this.#topOffset}px 0px 0px 0px` } + ) + + this.observer.observe(this.headerTarget) + this.previousTopOffset = this.#topOffset + } + + #stopObserver() { + this.observer?.disconnect() + } + + #updateObserverIfNeeded() { + if (this.#topOffset === this.previousTopOffset) return + + this.#stopObserver() + this.#startObserver() + } + + #windowResized = () => { + this.#updateObserverIfNeeded() + } + + get #title() { + return this.titleValue ? this.titleValue : document.title + } + + get #topOffset() { + return viewport.top + } +} diff --git a/app/javascript/controllers/bubble_controller.js b/app/javascript/controllers/bubble_controller.js index 714977c66..b8b7f6545 100644 --- a/app/javascript/controllers/bubble_controller.js +++ b/app/javascript/controllers/bubble_controller.js @@ -52,8 +52,11 @@ export default class extends Controller { this.#show() } + // Keep in sync with Card::Stallable#stalled? in app/models/card/stallable.rb get #isStalled() { - return this.stalledValue.lastActivitySpikeAt && signedDifferenceInDays(new Date(this.stalledValue.lastActivitySpikeAt), new Date()) > this.stalledValue.stalledAfterDays + return this.stalledValue.lastActivitySpikeAt && + signedDifferenceInDays(new Date(this.stalledValue.lastActivitySpikeAt), new Date()) > this.stalledValue.stalledAfterDays && + signedDifferenceInDays(new Date(this.stalledValue.updatedAt), new Date()) > this.stalledValue.stalledAfterDays } #showStalled() { diff --git a/app/javascript/controllers/card_hotkeys_controller.js b/app/javascript/controllers/card_hotkeys_controller.js index 564c076ad..62d02288d 100644 --- a/app/javascript/controllers/card_hotkeys_controller.js +++ b/app/javascript/controllers/card_hotkeys_controller.js @@ -79,7 +79,7 @@ export default class extends Controller { const selection = this.#selectedCard if (!selection) return - const url = selection.card.dataset.cardAssignToMeUrl + const url = selection.card.dataset.cardAssignToSelfUrl if (url) { event.preventDefault() await post(url, { responseKind: "turbo-stream" }) diff --git a/app/javascript/controllers/dialog_controller.js b/app/javascript/controllers/dialog_controller.js index f6449cea7..037c5ec7b 100644 --- a/app/javascript/controllers/dialog_controller.js +++ b/app/javascript/controllers/dialog_controller.js @@ -1,8 +1,9 @@ import { Controller } from "@hotwired/stimulus" import { orient } from "helpers/orientation_helpers" +import { isTouchDevice } from "helpers/platform_helpers" export default class extends Controller { - static targets = [ "dialog" ] + static targets = [ "dialog", "focusMouse", "focusTouch" ] static values = { modal: { type: Boolean, default: false }, sizing: { type: Boolean, default: true }, @@ -14,6 +15,10 @@ export default class extends Controller { if (this.autoOpenValue) this.open() } + focusTouchTargetConnected() { + this.#setupFocus() + } + open() { const modal = this.modalValue @@ -63,4 +68,10 @@ export default class extends Controller { captureKey(event) { if (event.key !== "Escape") { event.stopPropagation() } } + + #setupFocus() { + const touch = isTouchDevice() + if (this.hasFocusMouseTarget) this.focusMouseTarget.autofocus = !touch + if (this.hasFocusTouchTarget) this.focusTouchTarget.autofocus = touch + } } diff --git a/app/javascript/helpers/bridge/viewport_helpers.js b/app/javascript/helpers/bridge/viewport_helpers.js new file mode 100644 index 000000000..559143607 --- /dev/null +++ b/app/javascript/helpers/bridge/viewport_helpers.js @@ -0,0 +1,24 @@ +let top = 0 +const viewportTarget = window.visualViewport || window + +export const viewport = { + get top() { + return top + }, + get height() { + return viewportTarget.height || window.innerHeight + } +} + +function update() { + requestAnimationFrame(() => { + const styles = getComputedStyle(document.documentElement) + const customInset = styles.getPropertyValue("--custom-safe-inset-top") + const fallbackInset = styles.getPropertyValue("--safe-area-inset-top") + const insetValue = (customInset || fallbackInset).trim() + top = parseInt(insetValue || "0", 10) || 0 + }) +} + +viewportTarget.addEventListener("resize", update) +update() diff --git a/app/javascript/initializers/bridge/bridge_element.js b/app/javascript/initializers/bridge/bridge_element.js new file mode 100644 index 000000000..7ddeb1b33 --- /dev/null +++ b/app/javascript/initializers/bridge/bridge_element.js @@ -0,0 +1,18 @@ +import { BridgeElement } from "@hotwired/hotwire-native-bridge" + +BridgeElement.prototype.getButton = function() { + return { + title: this.title, + icon: this.getIcon() + } +} + +BridgeElement.prototype.getIcon = function() { + const url = this.bridgeAttribute(`icon-url`) + + if (url) { + return { url } + } + + return null +} diff --git a/app/javascript/initializers/index.js b/app/javascript/initializers/index.js index 10fb36975..90ef36a26 100644 --- a/app/javascript/initializers/index.js +++ b/app/javascript/initializers/index.js @@ -1 +1,2 @@ import "initializers/current" +import "initializers/bridge/bridge_element" diff --git a/app/jobs/card/clean_inaccessible_data_job.rb b/app/jobs/card/clean_inaccessible_data_job.rb new file mode 100644 index 000000000..c3fbef936 --- /dev/null +++ b/app/jobs/card/clean_inaccessible_data_job.rb @@ -0,0 +1,7 @@ +class Card::CleanInaccessibleDataJob < ApplicationJob + discard_on ActiveJob::DeserializationError + + def perform(card) + card.clean_inaccessible_data + end +end diff --git a/app/models/card.rb b/app/models/card.rb index 18957cb9b..d76512093 100644 --- a/app/models/card.rb +++ b/app/models/card.rb @@ -1,5 +1,5 @@ class Card < ApplicationRecord - include Assignable, Attachments, Broadcastable, Closeable, Colored, Entropic, Eventable, + include Accessible, Assignable, Attachments, Broadcastable, Closeable, Colored, Entropic, Eventable, Exportable, Golden, Mentions, Multistep, Pinnable, Postponable, Promptable, Readable, Searchable, Stallable, Statuses, Storage::Tracked, Taggable, Triageable, Watchable @@ -46,12 +46,6 @@ class Card < ApplicationRecord end end - delegate :accessible_to?, to: :board - - def publicly_accessible? - published? && board.publicly_accessible? - end - def card self end @@ -135,16 +129,13 @@ class Card < ApplicationRecord end remove_inaccessible_notifications_later + clean_inaccessible_data_later end def track_board_change_event(old_board_name) track_event "board_changed", particulars: { old_board: old_board_name, new_board: board.name } end - def grant_access_to_assignees - board.accesses.grant_to(assignees) - end - def assign_number self.number ||= account.increment!(:cards_count).cards_count end diff --git a/app/models/card/accessible.rb b/app/models/card/accessible.rb new file mode 100644 index 000000000..e74e0b914 --- /dev/null +++ b/app/models/card/accessible.rb @@ -0,0 +1,26 @@ +module Card::Accessible + extend ActiveSupport::Concern + + included do + delegate :accessible_to?, to: :board + end + + def publicly_accessible? + published? && board.publicly_accessible? + end + + def clean_inaccessible_data + accessible_user_ids = board.accesses.pluck(:user_id) + pins.where.not(user_id: accessible_user_ids).in_batches.destroy_all + watches.where.not(user_id: accessible_user_ids).in_batches.destroy_all + end + + private + def grant_access_to_assignees + board.accesses.grant_to(assignees) + end + + def clean_inaccessible_data_later + Card::CleanInaccessibleDataJob.perform_later(self) + end +end diff --git a/app/models/card/stallable.rb b/app/models/card/stallable.rb index 03348dfde..9e82b3fa1 100644 --- a/app/models/card/stallable.rb +++ b/app/models/card/stallable.rb @@ -13,6 +13,7 @@ module Card::Stallable after_update_commit :detect_activity_spikes_later, if: :should_detect_activity_spikes? end + # Keep in sync with #isStalled in app/javascript/controllers/bubble_controller.js def stalled? if activity_spike.present? open? && last_activity_spike_at < STALLED_AFTER_LAST_SPIKE_PERIOD.ago && updated_at < STALLED_AFTER_LAST_SPIKE_PERIOD.ago diff --git a/app/views/account/settings/show.html.erb b/app/views/account/settings/show.html.erb index 74e274414..438560635 100644 --- a/app/views/account/settings/show.html.erb +++ b/app/views/account/settings/show.html.erb @@ -1,7 +1,7 @@ <% @page_title = "Account Settings" %> <% content_for :header do %> -

+

<%= @page_title %> <% unless Current.user.admin? %>
Only admins can change these settings
diff --git a/app/views/active_storage/blobs/web/_representation.html.erb b/app/views/active_storage/blobs/web/_representation.html.erb index ca934f469..82a1a2d96 100644 --- a/app/views/active_storage/blobs/web/_representation.html.erb +++ b/app/views/active_storage/blobs/web/_representation.html.erb @@ -12,14 +12,14 @@ height: height %> <% elsif blob.audio? %> <% elsif blob.variable? %> - <%= link_to url_for(blob.variant(variant)), data: { lightbox_target: "image", lightbox_caption_value: blob.filename.to_s } do %> - <%= image_tag url_for(blob.variant(variant)), width: width, height: height %> + <%= link_to rails_representation_path(blob.variant(variant)), data: { lightbox_target: "image", lightbox_caption_value: blob.filename.to_s } do %> + <%= image_tag rails_representation_path(blob.variant(variant)), width: width, height: height %> <% end %> <% elsif blob.previewable? %> - <%= image_tag url_for(blob.preview(variant)), width: width, height: height %> + <%= image_tag rails_representation_path(blob.preview(variant)), width: width, height: height %> <% else %> <%= blob.filename.extension&.downcase.presence || "unknown" %> <% end %> diff --git a/app/views/boards/columns/closeds/show.html.erb b/app/views/boards/columns/closeds/show.html.erb index 110db1c8c..ca6c48e83 100644 --- a/app/views/boards/columns/closeds/show.html.erb +++ b/app/views/boards/columns/closeds/show.html.erb @@ -5,7 +5,7 @@ <%= link_back_to_board(@board) %> -

+

<%= @page_title %>

<% end %> diff --git a/app/views/boards/columns/not_nows/show.html.erb b/app/views/boards/columns/not_nows/show.html.erb index f744635ae..e7de8a522 100644 --- a/app/views/boards/columns/not_nows/show.html.erb +++ b/app/views/boards/columns/not_nows/show.html.erb @@ -5,7 +5,7 @@ <%= link_back_to_board(@board) %> -

+

<%= @page_title %>

<% end %> diff --git a/app/views/boards/columns/show.html.erb b/app/views/boards/columns/show.html.erb index cd6c396e7..e514b75ae 100644 --- a/app/views/boards/columns/show.html.erb +++ b/app/views/boards/columns/show.html.erb @@ -5,7 +5,7 @@ <%= link_back_to_board(@column.board) %> -

+

<%= @page_title %>

<% end %> diff --git a/app/views/boards/columns/streams/show.html.erb b/app/views/boards/columns/streams/show.html.erb index 6ec6ac339..26dce763d 100644 --- a/app/views/boards/columns/streams/show.html.erb +++ b/app/views/boards/columns/streams/show.html.erb @@ -5,7 +5,7 @@ <%= link_back_to_board(@board) %> -

+

<%= @page_title %>

<% end %> diff --git a/app/views/boards/edit.html.erb b/app/views/boards/edit.html.erb index 36beee3c8..978208e92 100644 --- a/app/views/boards/edit.html.erb +++ b/app/views/boards/edit.html.erb @@ -5,7 +5,7 @@ <%= link_back_to_board(@board) %> -

+

<%= @page_title %>
<% unless Current.user.can_administer_board?(@board) %>
Only admins can change these settings
diff --git a/app/views/boards/new.html.erb b/app/views/boards/new.html.erb index 748028b8d..928237a84 100644 --- a/app/views/boards/new.html.erb +++ b/app/views/boards/new.html.erb @@ -1,11 +1,11 @@ <% @page_title = "Create a new board" %>
- <%= form_with model: @board, class: "flex flex-column gap", data: { controller: "form", action: "submit->form#preventEmptySubmit" } do |form| %> + <%= bridged_form_with model: @board, class: "flex flex-column gap", data: { controller: "form", action: "submit->form#preventEmptySubmit" } do |form| %>

<%= @page_title %>

<%= form.text_field :name, required: true, class: "input full-width", autofocus: true, autocomplete: "off", placeholder: "Name it…", data: { form_target: "input", action: "keydown.esc@document->form#cancel", validation_message: "Board names can‘t be blank" } %> - diff --git a/app/views/boards/show.html.erb b/app/views/boards/show.html.erb index 36f3cd552..bcccf4512 100644 --- a/app/views/boards/show.html.erb +++ b/app/views/boards/show.html.erb @@ -5,15 +5,15 @@ <%= turbo_stream_from @board %> <% content_for :header do %> -
+
<%= link_to_webhooks(@board) if Current.user.admin? %>
-

+

<%= @board.name %>

-
+
<%= link_to_edit_board @board %>
<% end %> diff --git a/app/views/boards/show/_stream.html.erb b/app/views/boards/show/_stream.html.erb index 7773fb292..b43309100 100644 --- a/app/views/boards/show/_stream.html.erb +++ b/app/views/boards/show/_stream.html.erb @@ -7,7 +7,9 @@ <%= render "boards/show/expander", title: "Maybe?", count: board.cards.awaiting_triage.count, column_id: "maybe" %> <%= render "boards/show/menu/maximize", column_path: board_columns_stream_path(board) %> - <%= render "columns/show/add_card_button", board: board %> +
+ <%= render "columns/show/add_card_button", board: board %> +
<% if page.used? %> <%= with_automatic_pagination "maybe", @page do %> diff --git a/app/views/boards/show/menu/_column.html.erb b/app/views/boards/show/menu/_column.html.erb index fa3ab38ff..ebfdd3855 100644 --- a/app/views/boards/show/menu/_column.html.erb +++ b/app/views/boards/show/menu/_column.html.erb @@ -3,7 +3,7 @@ <%= icon_tag "menu-dots-horizontal", class: "translucent" %> Column options - +
-<% end %> +
+ <%= render "cards/display/common/board", card: card do %> + <%= icon_tag "caret-down", class: "txt-xx-small", hidden: card.closed? %> + <% end %> + + + <%= turbo_frame_tag "board_picker", src: edit_card_board_path(card), target: "_top", loading: :lazy, refresh: "morph" %> + +
diff --git a/app/views/cards/display/perma/_tags.html.erb b/app/views/cards/display/perma/_tags.html.erb index 2fb747d27..ea4d1f76b 100644 --- a/app/views/cards/display/perma/_tags.html.erb +++ b/app/views/cards/display/perma/_tags.html.erb @@ -1,7 +1,7 @@
-
> -
-

<%= @page_title %>

+

<%= @page_title %>

<% end %>
diff --git a/app/views/sessions/menus/show.html.erb b/app/views/sessions/menus/show.html.erb index 9c6867740..09383b72a 100644 --- a/app/views/sessions/menus/show.html.erb +++ b/app/views/sessions/menus/show.html.erb @@ -13,7 +13,7 @@ <% @accounts.each do |account| %> diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index 7cf5bdf7c..771a8e924 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -41,7 +41,7 @@ <% if Current.user == @user %> - <%= button_to session_url(script_name: nil), method: :delete, class: "btn txt-x-small center", data: { turbo: false } do %> + <%= button_to session_path(script_name: nil), method: :delete, class: "btn txt-x-small center", data: { turbo: false } do %> Sign out of Fizzy on this device <% end %> <% end %> diff --git a/app/views/webhooks/form/_actions.html.erb b/app/views/webhooks/form/_actions.html.erb index 95a287abe..e4126225e 100644 --- a/app/views/webhooks/form/_actions.html.erb +++ b/app/views/webhooks/form/_actions.html.erb @@ -1,17 +1,24 @@ -
    - <%= form.collection_check_boxes \ - :subscribed_actions, - webhook_action_options, - :first, - :last do |item| %> -
  • - -
  • - <% end %> -
+
+
+ <%= button_tag "Enable all", type: "button", class: "btn btn--plain txt-x-small txt-link font-weight-normal", data: { action: "click->toggle-class#checkAll" } %> + · + <%= button_tag "Disable all", type: "button", class: "btn btn--plain txt-x-small txt-link font-weight-normal", data: { action: "click->toggle-class#checkNone" } %> +
+
    + <%= form.collection_check_boxes \ + :subscribed_actions, + webhook_action_options, + :first, + :last do |item| %> +
  • + +
  • + <% end %> +
+
diff --git a/app/views/webhooks/index.html.erb b/app/views/webhooks/index.html.erb index e8f72c0a4..987ae0094 100644 --- a/app/views/webhooks/index.html.erb +++ b/app/views/webhooks/index.html.erb @@ -5,7 +5,7 @@ <%= link_back_to_board(@board) %>
-

<%= @page_title %>

+

<%= @page_title %>

<% end %> <%= tag.section class: "panel panel--wide shadow center webhooks" do %> diff --git a/app/views/webhooks/new.html.erb b/app/views/webhooks/new.html.erb index e1a6af513..9d2873ece 100644 --- a/app/views/webhooks/new.html.erb +++ b/app/views/webhooks/new.html.erb @@ -5,7 +5,7 @@ <%= back_link_to "Webhooks", board_webhooks_path, "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
-

<%= @page_title %>

+

<%= @page_title %>

<% end %>
diff --git a/config/importmap.rb b/config/importmap.rb index 3431633f9..27dbd7015 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -4,11 +4,15 @@ pin "application" pin "@hotwired/turbo-rails", to: "turbo.min.js" pin "@hotwired/stimulus", to: "stimulus.min.js" pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" +pin "@hotwired/hotwire-native-bridge", to: "@hotwired--hotwire-native-bridge.js" pin "@rails/request.js", to: "@rails--request.js" # @0.0.13 pin_all_from "app/javascript/controllers", under: "controllers" pin_all_from "app/javascript/helpers", under: "helpers" pin_all_from "app/javascript/initializers", under: "initializers" +pin_all_from "app/javascript/bridge/initializers", under: "bridge/initializers" +pin_all_from "app/javascript/bridge/helpers", under: "bridge/helpers" +pin_all_from "app/javascript/bridge/controllers/bridge", under: "controllers/bridge", to: "bridge/controllers/bridge" pin "marked" # @15.0.11 pin "lexxy" pin "@rails/activestorage", to: "activestorage.esm.js" diff --git a/config/initializers/active_storage.rb b/config/initializers/active_storage.rb index 3edd75cc8..586c292a1 100644 --- a/config/initializers/active_storage.rb +++ b/config/initializers/active_storage.rb @@ -8,6 +8,17 @@ ActiveSupport.on_load(:active_storage_blob) do end end +ActiveSupport.on_load(:action_text_content) do + # Install our extensions after ActionText::Engine's + ActiveSupport.on_load(:active_storage_blob) do + # Ensure all s have a "url" attribute that's a relative + # path (for portability across host name changes, beta environments, etc). + def to_rich_text_attributes(*) + super.merge url: Rails.application.routes.url_helpers.polymorphic_url(self, only_path: true) + end + end +end + # Don't configure replica connections for ActiveStorage::Record. # When ActiveStorage uses `connects_to`, it creates a separate connection pool # from ApplicationRecord. This causes after_commit callbacks to fire in diff --git a/config/routes.rb b/config/routes.rb index 97e34290c..3a76e8b7a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -87,6 +87,7 @@ Rails.application.routes.draw do resource :reading resources :assignments + resource :self_assignment, only: :create resources :steps resources :taggings diff --git a/saas/app/controllers/admin/stats_controller.rb b/saas/app/controllers/admin/stats_controller.rb index 6d508fc2c..6b9592fca 100644 --- a/saas/app/controllers/admin/stats_controller.rb +++ b/saas/app/controllers/admin/stats_controller.rb @@ -6,6 +6,10 @@ class Admin::StatsController < AdminController @accounts_last_7_days = Account.where(created_at: 7.days.ago..).count @accounts_last_24_hours = Account.where(created_at: 24.hours.ago..).count + @paid_accounts_total = paid_subscriptions.distinct.count(:account_id) + @paid_accounts_last_7_days = paid_subscriptions.where(created_at: 7.days.ago..).distinct.count(:account_id) + @paid_accounts_last_24_hours = paid_subscriptions.where(created_at: 24.hours.ago..).distinct.count(:account_id) + @identities_total = Identity.count @identities_last_7_days = Identity.where(created_at: 7.days.ago..).count @identities_last_24_hours = Identity.where(created_at: 24.hours.ago..).count @@ -17,4 +21,12 @@ class Admin::StatsController < AdminController @recent_accounts = Account.order(created_at: :desc).limit(10) end + + private + def paid_subscriptions + Account::Subscription + .where(status: %w[active trialing past_due]) + .where(plan_key: %w[monthly_v1 monthly_extra_storage_v1]) + .where.not(account_id: Account::BillingWaiver.select(:account_id)) + end end diff --git a/saas/app/views/admin/stats/show.html.erb b/saas/app/views/admin/stats/show.html.erb index 7c7491d2a..cb999172e 100644 --- a/saas/app/views/admin/stats/show.html.erb +++ b/saas/app/views/admin/stats/show.html.erb @@ -33,6 +33,32 @@
+
+
+

Accounts Subscribed

+
+
+
+
Total
+
+ <%= @paid_accounts_total %> +
+
+
+
7 days
+
+ <%= @paid_accounts_last_7_days %> +
+
+
+
24 hours
+
+ <%= @paid_accounts_last_24_hours %> +
+
+
+
+

Identities Created

diff --git a/script/migrations/convert-absolute-attachment-urls-to-relative.rb b/script/migrations/convert-absolute-attachment-urls-to-relative.rb new file mode 100644 index 000000000..009b6c4cb --- /dev/null +++ b/script/migrations/convert-absolute-attachment-urls-to-relative.rb @@ -0,0 +1,132 @@ +#!/usr/bin/env ruby + +# Convert absolute attachment URLs in rich text content to relative paths. +# This fixes URLs that were stored with full hostnames (e.g., https://app.fizzy.do/...) +# making them portable across beta environments and host changes. +# +# MUST BE RUN AFTER `decrypt!` when using ActiveRecord Encryption +# +# Run locally: +# bin/rails runner script/migrations/convert-absolute-attachment-urls-to-relative.rb --help +# +# Run via Kamal: +# kamal app exec -d -p --reuse "bin/rails runner script/migrations/convert-absolute-attachment-urls-to-relative.rb --help" +# +# Safe to re-run: won't modify already-relative URLs + +class ConvertAbsoluteAttachmentUrlsToRelative + # Match absolute URLs pointing to Active Storage routes, keeping the account slug + ABSOLUTE_URL_PATTERN = %r{https?://[^/]+(/\d+/rails/active_storage/[^"']+)} + + attr_reader :account, :dry_run + + def initialize(account_id: nil, dry_run: false) + @account = Account.find_by(external_account_id: account_id) + @dry_run = dry_run + end + + def run + puts "Converting absolute attachment URLs to relative paths" + puts dry_run ? "DRY RUN MODE - no changes will be saved" : "LIVE MODE - changes will be saved" + puts account ? "Only account: #{account.external_account_id} - #{account.name}" : "For **ALL ACCOUNTS**" + + puts "\nPress ENTER to continue running or CTRL-C to bail..." + gets + + puts "\nRunning..." + + # Suppress SQL logs + Rails.event.debug_mode = false + + seconds = Benchmark.realtime do + suppressing_turbo_broadcasts do + convert_urls + end + end + + puts "\n\n" + puts "Finished in %.2f seconds." % seconds + end + + private + def suppressing_turbo_broadcasts + Board.suppressing_turbo_broadcasts do + Card.suppressing_turbo_broadcasts do + yield + end + end + end + + def convert_urls + scanned = 0 + fixed = 0 + urls_converted = 0 + + action_texts_scope.find_each do |rich_text| + scanned += 1 + + body = rich_text.body + + edited = false + conversions = 0 + + body.send(:attachment_nodes).each do |node| + url = node["url"] + next unless url + + if url.match?(ABSOLUTE_URL_PATTERN) + node["url"] = url.gsub(ABSOLUTE_URL_PATTERN, '\1') + edited = true + conversions += 1 + end + end + + if edited + record = rich_text.record + puts " - modifying #{record.class.name} #{record.to_param} (account: #{record.account&.external_account_id}) - #{conversions} URL(s)" + + unless dry_run + rich_text.update! body: body.fragment.to_html + end + + fixed += 1 + urls_converted += conversions + end + end + + puts "\n\nConversion complete!" + puts " Rich texts examined: #{scanned}" + puts " Rich texts modified: #{fixed}" + puts " URLs converted: #{urls_converted}" + end + + def action_texts_scope + # Only examine rich texts that have embedded attachments + scope = ActionText::RichText.joins(:embeds_attachments) + scope = scope.where(account: account) if account + scope + end +end + +require "optparse" + +options = { account_id: nil, dry_run: true } + +OptionParser.new do |opts| + opts.banner = "Usage: bin/rails runner #{__FILE__} [options]" + + opts.on("-a", "--account ACCOUNT_ID", "Restrict to a specific account (external_account_id)") do |id| + options[:account_id] = id + end + + opts.on("--[no-]dry-run", "Run in dry-run mode (default: --dry-run)") do |v| + options[:dry_run] = v + end + + opts.on("-h", "--help", "Show this help message") do + puts opts + exit + end +end.parse! + +ConvertAbsoluteAttachmentUrlsToRelative.new(**options).run diff --git a/script/migrations/convert-relative-attachment-urls-to-absolute.rb b/script/migrations/convert-relative-attachment-urls-to-absolute.rb new file mode 100644 index 000000000..4b201f34f --- /dev/null +++ b/script/migrations/convert-relative-attachment-urls-to-absolute.rb @@ -0,0 +1,127 @@ +#!/usr/bin/env ruby + +# Rollback script: Convert relative attachment URLs back to absolute URLs. +# Use this if you need to rollback the relative URL changes and want to +# convert rich texts created during the rollout back to absolute URLs. +# +# Run locally: +# bin/rails runner script/migrations/convert-relative-attachment-urls-to-absolute.rb --help +# +# Run via Kamal: +# kamal app exec -d -p --reuse "bin/rails runner script/migrations/convert-relative-attachment-urls-to-absolute.rb --help" + +class ConvertRelativeAttachmentUrlsToAbsolute + # Match relative URLs pointing to Active Storage routes (with account slug) + RELATIVE_URL_PATTERN = %r{\A(/\d+/rails/active_storage/[^"']+)\z} + + attr_reader :host, :since + + def initialize(host:, since:) + @host = host + @since = since + end + + def run + puts "Converting relative attachment URLs to absolute URLs" + puts "Host: #{host}" + puts "Processing rich texts created since: #{since}" + + puts "\nPress ENTER to continue running or CTRL-C to bail..." + gets + + puts "\nRunning..." + + seconds = Benchmark.realtime do + suppressing_turbo_broadcasts do + convert_urls + end + end + + puts "\n\n" + puts "Finished in %.2f seconds." % seconds + end + + private + def suppressing_turbo_broadcasts + Board.suppressing_turbo_broadcasts do + Card.suppressing_turbo_broadcasts do + yield + end + end + end + + def convert_urls + scanned = 0 + fixed = 0 + urls_converted = 0 + + action_texts_scope.find_each do |rich_text| + scanned += 1 + + body = rich_text.body + + edited = false + conversions = 0 + + body.send(:attachment_nodes).each do |node| + url = node["url"] + next unless url + + if url.match?(RELATIVE_URL_PATTERN) + node["url"] = "#{host}#{url}" + edited = true + conversions += 1 + end + end + + if edited + record = rich_text.record + puts " - modifying #{record.class.name} #{record.to_param} (account: #{record.account&.external_account_id}) - #{conversions} URL(s)" + + rich_text.update! body: body.fragment.to_html + + fixed += 1 + urls_converted += conversions + end + end + + puts "\n\nConversion complete!" + puts " Rich texts examined: #{scanned}" + puts " Rich texts modified: #{fixed}" + puts " URLs converted: #{urls_converted}" + end + + def action_texts_scope + ActionText::RichText.joins(:embeds_attachments).where("action_text_rich_texts.created_at >= ?", since) + end +end + +require "optparse" +require "time" + +options = {} + +OptionParser.new do |opts| + opts.banner = "Usage: bin/rails runner #{__FILE__} [options]" + + opts.on("--host HOST", "Host to prepend (e.g., https://app.fizzy.do)") do |host| + options[:host] = host + end + + opts.on("--since TIME", "Process rich texts created since this time (ISO 8601 format)") do |time| + options[:since] = Time.parse(time) + end + + opts.on("-h", "--help", "Show this help message") do + puts opts + exit + end +end.parse! + +if options[:host].nil? || options[:since].nil? + puts "Error: --host and --since are required" + puts "Example: bin/rails runner #{__FILE__} --host https://app.fizzy.do --since 2026-01-14T10:00:00Z" + exit 1 +end + +ConvertRelativeAttachmentUrlsToAbsolute.new(**options).run diff --git a/test/controllers/cards/self_assignments_controller_test.rb b/test/controllers/cards/self_assignments_controller_test.rb new file mode 100644 index 000000000..3edde8fec --- /dev/null +++ b/test/controllers/cards/self_assignments_controller_test.rb @@ -0,0 +1,44 @@ +require "test_helper" + +class Cards::SelfAssignmentsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :kevin + end + + test "create assigns to current user" do + card = cards(:layout) + + assert_not card.assigned_to?(users(:kevin)) + + post card_self_assignment_path(card), as: :turbo_stream + assert_response :success + assert_meta_replaced(card) + assert card.reload.assigned_to?(users(:kevin)) + end + + test "create toggles off when already assigned" do + card = cards(:logo) + + assert card.assigned_to?(users(:kevin)) + + post card_self_assignment_path(card), as: :turbo_stream + assert_response :success + assert_meta_replaced(card) + assert_not card.reload.assigned_to?(users(:kevin)) + end + + test "create as JSON" do + card = cards(:layout) + + assert_not card.assigned_to?(users(:kevin)) + + post card_self_assignment_path(card), as: :json + assert_response :no_content + assert card.reload.assigned_to?(users(:kevin)) + end + + private + def assert_meta_replaced(card) + assert_turbo_stream action: :replace, target: dom_id(card, :meta) + end +end diff --git a/test/controllers/cards_controller_test.rb b/test/controllers/cards_controller_test.rb index 11c4b62a5..8540912e6 100644 --- a/test/controllers/cards_controller_test.rb +++ b/test/controllers/cards_controller_test.rb @@ -52,6 +52,16 @@ class CardsControllerTest < ActionDispatch::IntegrationTest assert_response :success end + test "edit card with invalid attachments in description" do + card = cards(:logo) + card.update! description: <<~HTML + + HTML + + get edit_card_path(card) + assert_response :success + end + test "update" do patch card_path(cards(:logo)), as: :turbo_stream, params: { card: { diff --git a/test/helpers/entropy_helper_test.rb b/test/helpers/entropy_helper_test.rb new file mode 100644 index 000000000..144c523ea --- /dev/null +++ b/test/helpers/entropy_helper_test.rb @@ -0,0 +1,30 @@ +require "test_helper" + +class EntropyHelperTest < ActionView::TestCase + test "stalled_bubble_options_for returns nil when card has no activity spike" do + assert_nil stalled_bubble_options_for(cards(:logo)) + end + + test "stalled_bubble_options_for returns options when card has activity spike" do + card = cards(:logo) + card.create_activity_spike! + + options = stalled_bubble_options_for(card) + assert_not_nil options + assert_equal card.last_activity_spike_at.iso8601, options[:lastActivitySpikeAt] + end + + test "stalled_bubble_options_for includes updatedAt for client-side staleness check" do + card = cards(:logo) + card.create_activity_spike! + + travel_to 3.months.from_now + + # Touch the card to simulate step completion + card.touch + + options = stalled_bubble_options_for(card) + # The helper must include updatedAt so JS can check if card was recently updated + assert_equal card.updated_at.iso8601, options[:updatedAt] + end +end diff --git a/test/models/card_test.rb b/test/models/card_test.rb index 9c7d45add..bb72fddf8 100644 --- a/test/models/card_test.rb +++ b/test/models/card_test.rb @@ -163,4 +163,48 @@ class CardTest < ActiveSupport::TestCase assert_not Card.new.filled? end + + test "pins are deleted when card moves to a board user cannot access" do + card = cards(:logo) + kevin = users(:kevin) + david = users(:david) + + # David pins the card (Kevin already has it pinned via fixture) + card.pin_by(david) + + assert card.pinned_by?(kevin) + assert card.pinned_by?(david) + + # Kevin has access to the private board, David does not + assert boards(:private).accessible_to?(kevin) + assert_not boards(:private).accessible_to?(david) + + perform_enqueued_jobs only: Card::CleanInaccessibleDataJob do + card.move_to(boards(:private)) + end + + assert card.pinned_by?(kevin), "Kevin's pin should remain (has board access)" + assert_not card.pinned_by?(david), "David's pin should be deleted (no board access)" + end + + test "watches are deleted when card moves to a board user cannot access" do + card = cards(:logo) + kevin = users(:kevin) + david = users(:david) + + # Both watch the card via fixtures + assert card.watched_by?(kevin) + assert card.watched_by?(david) + + # Kevin has access to the private board, David does not + assert boards(:private).accessible_to?(kevin) + assert_not boards(:private).accessible_to?(david) + + perform_enqueued_jobs only: Card::CleanInaccessibleDataJob do + card.move_to(boards(:private)) + end + + assert card.watched_by?(kevin), "Kevin's watch should remain (has board access)" + assert_not card.watched_by?(david), "David's watch should be deleted (no board access)" + end end diff --git a/test/models/notification_test.rb b/test/models/notification_test.rb index cb230070a..9c8ebbe12 100644 --- a/test/models/notification_test.rb +++ b/test/models/notification_test.rb @@ -15,7 +15,9 @@ class NotificationTest < ActiveSupport::TestCase notification.read # Mark as read first assert_turbo_stream_broadcasts([ notification.user, :notifications ], count: 1) do - notification.unread + perform_enqueued_jobs do + notification.unread + end end end diff --git a/vendor/javascript/@hotwired--hotwire-native-bridge.js b/vendor/javascript/@hotwired--hotwire-native-bridge.js new file mode 100644 index 000000000..c7e9927a7 --- /dev/null +++ b/vendor/javascript/@hotwired--hotwire-native-bridge.js @@ -0,0 +1,4 @@ +// @hotwired/hotwire-native-bridge@1.2.2 downloaded from https://ga.jspm.io/npm:@hotwired/hotwire-native-bridge@1.2.2/dist/hotwire-native-bridge.js + +import{Controller as e}from"@hotwired/stimulus";var t=class{#e;#t;#s;#n;constructor(){this.#e=null;this.#t=0;this.#s=[];this.#n=new Map}start(){this.notifyApplicationAfterStart()}notifyApplicationAfterStart(){document.dispatchEvent(new Event("web-bridge:ready"))}supportsComponent(e){return!!this.#e&&this.#e.supportsComponent(e)}send({component:e,event:t,data:s,callback:n}){if(!this.#e){this.#i({component:e,event:t,data:s,callback:n});return null}if(!this.supportsComponent(e))return null;const i=this.generateMessageId();const r={id:i,component:e,event:t,data:s||{}};this.#e.receive(r);n&&this.#n.set(i,n);return i}receive(e){this.executeCallbackFor(e)}executeCallbackFor(e){const t=this.#n.get(e.id);t&&t(e)}removeCallbackFor(e){this.#n.has(e)&&this.#n.delete(e)}removePendingMessagesFor(e){this.#s=this.#s.filter((t=>t.component!=e))}generateMessageId(){const e=++this.#t;return e.toString()}setAdapter(e){this.#e=e;document.documentElement.dataset.bridgePlatform=this.#e.platform;this.adapterDidUpdateSupportedComponents();this.#r()}adapterDidUpdateSupportedComponents(){this.#e&&(document.documentElement.dataset.bridgeComponents=this.#e.supportedComponents.join(" "))}#i(e){this.#s.push(e)}#r(){this.#s.forEach((e=>this.send(e)));this.#s=[]}};var s=class{constructor(e){this.element=e}get title(){return(this.bridgeAttribute("title")||this.attribute("aria-label")||this.element.textContent||this.element.value).trim()}get enabled(){return!this.disabled}get disabled(){const e=this.bridgeAttribute("disabled");return e==="true"||e===this.platform}enableForComponent(e){e.enabled&&this.removeBridgeAttribute("disabled")}hasClass(e){return this.element.classList.contains(e)}attribute(e){return this.element.getAttribute(e)}bridgeAttribute(e){return this.attribute(`data-bridge-${e}`)}setBridgeAttribute(e,t){this.element.setAttribute(`data-bridge-${e}`,t)}removeBridgeAttribute(e){this.element.removeAttribute(`data-bridge-${e}`)}click(){this.platform=="android"&&this.element.removeAttribute("target");this.element.click()}get platform(){return document.documentElement.dataset.bridgePlatform}};var{userAgent:n}=window.navigator;function i(e){const t=n.match(/bridge-components: \[(.*?)\]/);return!!t&&t[1].split(" ").includes(e)}var r=class extends e{static component="";static get shouldLoad(){return i(this.component)}pendingMessageCallbacks=[];initialize(){this.pendingMessageCallbacks=[]}connect(){this.removeRestoreEventListener();this.addRestoreEventListener()}disconnect(){this.removePendingCallbacks();this.removePendingMessages();this.removeRestoreEventListener()}addRestoreEventListener(){this.restore=this.restore.bind(this);document.addEventListener("native:restore",this.restore)}removeRestoreEventListener(){document.removeEventListener("native:restore",this.restore)}restore(){this.connect()}get component(){return this.constructor.component}get platformOptingOut(){const{bridgePlatform:e}=document.documentElement.dataset;return this.identifier==this.element.getAttribute(`data-controller-optout-${e}`)}get enabled(){return!this.platformOptingOut&&this.bridge.supportsComponent(this.component)}send(e,t={},s){t.metadata={url:window.location.href};const n={component:this.component,event:e,data:t,callback:s};const i=this.bridge.send(n);s&&this.pendingMessageCallbacks.push(i)}removePendingCallbacks(){this.pendingMessageCallbacks.forEach((e=>this.bridge.removeCallbackFor(e)))}removePendingMessages(){this.bridge.removePendingMessagesFor(this.component)}get bridgeElement(){return new s(this.element)}get bridge(){return window.HotwireNative.web}};if(!window.HotwireNative){const e=new t;window.HotwireNative={web:e};a(e);e.start()}function a(e){window.Strada||(window.Strada={web:e});window.webBridge||(window.webBridge=e)}export{r as BridgeComponent,s as BridgeElement}; +