diff --git a/Gemfile.lock b/Gemfile.lock index 8fad5375c..ae7553c8d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,6 @@ GIT remote: https://github.com/basecamp/actiontext-lexical - revision: 38e8d4e5781667b7d1c17eaa5cca503bbc63ee67 + revision: 70ba099446c8a033c06c2bde9c5e70b7f983e60b specs: actiontext-lexical (0.1.0) rails (>= 8.0.2) @@ -444,7 +444,7 @@ GEM rainbow (3.1.1) rake (13.3.0) rb_sys (0.9.106) - rdoc (6.14.1) + rdoc (6.14.2) erb psych (>= 4.0.0) redcarpet (3.6.1) diff --git a/app/assets/stylesheets/comments.css b/app/assets/stylesheets/comments.css index a8b8da16b..6b1953d27 100644 --- a/app/assets/stylesheets/comments.css +++ b/app/assets/stylesheets/comments.css @@ -37,6 +37,8 @@ } .comment__author { + margin-inline-end: calc(var(--comment-padding-inline) * -0.66); + .comment--system & { h3 { margin-inline: auto; diff --git a/app/assets/stylesheets/rich-text-content.css b/app/assets/stylesheets/rich-text-content.css index a06575e98..a24c1845d 100644 --- a/app/assets/stylesheets/rich-text-content.css +++ b/app/assets/stylesheets/rich-text-content.css @@ -217,6 +217,8 @@ .attachment--file { align-items: center; display: inline-flex; + flex-wrap: wrap; + inline-size: auto; .attachment__caption { flex-direction: column; @@ -228,12 +230,17 @@ color: var(--color-ink); } } + + .attachment__name { + color: var(--color-ink); + font-weight: bold; + } } .attachment__icon { aspect-ratio: 4/5; background-color: var(--color-canvas); - block-size: calc(2.5lh + 0.5ch); + block-size: calc(2lh + (var(--block-space) - 2px)); border: 2px solid var(--color-ink-medium); border-radius: 0.3em; color: var(--color-ink-dark); @@ -252,7 +259,6 @@ block-size: 0.5lh; content: ""; display: block; - inline-size: 100%; inset: 0 0 auto; position: absolute; } diff --git a/app/controllers/searches_controller.rb b/app/controllers/searches_controller.rb index c788f1b52..1ccbaa245 100644 --- a/app/controllers/searches_controller.rb +++ b/app/controllers/searches_controller.rb @@ -3,6 +3,6 @@ class SearchesController < ApplicationController def show @search_results = Current.user.search(@query_terms).limit(50) - @recent_search_queries = Current.user.search_queries.order(created_at: :desc).limit(50).uniq(&:terms) + @recent_search_queries = Current.user.search_queries.order(updated_at: :desc).limit(10) end end diff --git a/app/javascript/controllers/terminal_controller.js b/app/javascript/controllers/terminal_controller.js index aa857479b..35681f97c 100644 --- a/app/javascript/controllers/terminal_controller.js +++ b/app/javascript/controllers/terminal_controller.js @@ -162,13 +162,15 @@ export default class extends Controller { this.waitingForConfirmationValue = true } - #showConfirmationPrompt(confirmationPrompt) { + async #showConfirmationPrompt(confirmationPrompt) { if (isMultiLineString(confirmationPrompt)) { this.#showOutput(confirmationPrompt) this.inputTarget.value = `Apply these changes? [Y/n] ` } else { this.inputTarget.value = `${confirmationPrompt}? [Y/n] ` } + + await nextFrame() } #handleConfirmationKey(key) { @@ -180,16 +182,17 @@ export default class extends Controller { } } - #submitWithConfirmation() { + async #submitWithConfirmation() { this.inputTarget.value = this.originalInputValue this.confirmationTarget.value = "confirmed" this.#hideOutput() + + await nextFrame() this.#submitCommand() } #submitCommand() { this.formTarget.requestSubmit() - this.#reset() } #showOutput(markdown) { diff --git a/app/models/card/mentions.rb b/app/models/card/mentions.rb new file mode 100644 index 000000000..cb3a34f17 --- /dev/null +++ b/app/models/card/mentions.rb @@ -0,0 +1,11 @@ +module Card::Mentions + extend ActiveSupport::Concern + + included do + include ::Mentions + + def mentionable? + published? + end + end +end diff --git a/app/models/command/ai/parser.rb b/app/models/command/ai/parser.rb index 59014f900..c2e1db3b3 100644 --- a/app/models/command/ai/parser.rb +++ b/app/models/command/ai/parser.rb @@ -30,7 +30,7 @@ class Command::Ai::Parser commands.unshift Command::VisitUrl.new(user: user, url: query_context.url, context: resolved_context) end - Command::Composite.new(title: query, commands: commands, user: user, line: normalized_query, context: resolved_context) + Command::Composite.new(title: query, commands: commands, user: user, line: query, context: resolved_context) end def commands_from_query(normalized_query, context) diff --git a/app/models/command/parser.rb b/app/models/command/parser.rb index d383a8e27..075162964 100644 --- a/app/models/command/parser.rb +++ b/app/models/command/parser.rb @@ -37,9 +37,7 @@ class Command::Parser when /^#/ Command::FilterByTag.new(tag_title: tag_title_from(string), params: filter.as_params) when /^@/ - Command::GoToUser.new(user_id: context.find_user(command_name)&.id) - when "/user" - Command::GoToUser.new(user_id: context.find_user(combined_arguments)&.id) + Command::GoToUser.new(user_id: context.find_user(string)&.id) when "/assign", "/assignto" Command::Assign.new(assignee_ids: assignees_from(command_arguments).collect(&:id), card_ids: cards.ids) when "/clear" @@ -64,11 +62,22 @@ class Command::Parser Command::VisitUrl.new(url: command_arguments.first) when "/tag" Command::Tag.new(tag_title: tag_title_from(combined_arguments), card_ids: cards.ids) + when /^gid:\/\// + parse_gid command_name else parse_free_string(string) end end + def parse_gid(command_name) + case record = GlobalID::Locator.locate(command_name) + when Tag + Command::FilterByTag.new(tag_title: record.title, params: filter.as_params) + when User + Command::GoToUser.new(user_id: record.id) + end + end + def assignees_from(strings) Array(strings).filter_map do |string| context.find_user(string) diff --git a/app/models/comment/mentions.rb b/app/models/comment/mentions.rb new file mode 100644 index 000000000..a4a0fc927 --- /dev/null +++ b/app/models/comment/mentions.rb @@ -0,0 +1,11 @@ +module Comment::Mentions + extend ActiveSupport::Concern + + included do + include ::Mentions + + def mentionable? + card.published? + end + end +end diff --git a/app/models/concerns/mentions.rb b/app/models/concerns/mentions.rb index 86d017533..fa50de543 100644 --- a/app/models/concerns/mentions.rb +++ b/app/models/concerns/mentions.rb @@ -4,7 +4,7 @@ module Mentions included do has_many :mentions, as: :source, dependent: :destroy has_many :mentionees, through: :mentions - after_save_commit :create_mentions_later, if: :mentionable_content_changed? + after_save_commit :create_mentions_later, if: :should_create_mentions? end def create_mentions(mentioner: Current.user) @@ -48,6 +48,10 @@ module Mentions self.class.reflect_on_all_associations(:has_one).filter { it.klass == ActionText::RichText } end + def should_create_mentions? + mentionable? && mentionable_content_changed? + end + def mentionable_content_changed? rich_text_associations.any? { send(it.name)&.body_previously_changed? } end @@ -55,4 +59,9 @@ module Mentions def create_mentions_later Mention::CreateJob.perform_later(self, mentioner: Current.user) end + + # Template method + def mentionable? + true + end end diff --git a/app/models/tag/attachable.rb b/app/models/tag/attachable.rb index 7b0a960e2..37d95b2dd 100644 --- a/app/models/tag/attachable.rb +++ b/app/models/tag/attachable.rb @@ -3,9 +3,9 @@ module Tag::Attachable included do include ActionText::Attachable - end - def attachable_plain_text_representation(...) - "##{title}" + def attachable_plain_text_representation(...) + "##{title}" + end end end diff --git a/app/models/user/attachable.rb b/app/models/user/attachable.rb index c1505535e..2ffde99c3 100644 --- a/app/models/user/attachable.rb +++ b/app/models/user/attachable.rb @@ -3,9 +3,9 @@ module User::Attachable included do include ActionText::Attachable - end - def attachable_plain_text_representation(...) - "@#{first_name.downcase}" + def attachable_plain_text_representation(...) + "@#{first_name.downcase}" + end end end diff --git a/app/models/user/searcher.rb b/app/models/user/searcher.rb index a47d9cc96..345676e4f 100644 --- a/app/models/user/searcher.rb +++ b/app/models/user/searcher.rb @@ -10,6 +10,8 @@ module User::Searcher end def remember_search(terms) - search_queries.create(terms: terms) if search_queries.last&.terms != terms + search_queries.find_or_create_by(terms: terms).tap do |search_query| + search_query.touch unless search_query.previously_new_record? + end end end diff --git a/app/views/cards/comments/_comment.html.erb b/app/views/cards/comments/_comment.html.erb index daa3eda11..8249b3884 100644 --- a/app/views/cards/comments/_comment.html.erb +++ b/app/views/cards/comments/_comment.html.erb @@ -8,7 +8,7 @@
+
<%= link_to comment.creator.name, comment.creator, class: "txt-ink btn btn--plain fill-transparent", data: { turbo_frame: "_top" } %> diff --git a/app/views/cards/container/_image.html.erb b/app/views/cards/container/_image.html.erb index 773f7f949..d08c68ed9 100644 --- a/app/views/cards/container/_image.html.erb +++ b/app/views/cards/container/_image.html.erb @@ -7,9 +7,10 @@ <%= form_with model: card, url: collection_card_path(card.collection, card), data: { controller: "form" } do |form| %> <% end %> diff --git a/config/initializers/tenanting/active_storage.rb b/config/initializers/tenanting/active_storage.rb index 278b92de8..f57cdc304 100644 --- a/config/initializers/tenanting/active_storage.rb +++ b/config/initializers/tenanting/active_storage.rb @@ -14,6 +14,6 @@ module ActiveStorageControllerExtensions end end -Rails.application.config.after_initialize do +Rails.application.config.to_prepare do ActiveStorage::BaseController.include ActiveStorageControllerExtensions end diff --git a/db/migrate/20250703193928_add_index_to_search_queries.rb b/db/migrate/20250703193928_add_index_to_search_queries.rb new file mode 100644 index 000000000..d8cd7bc59 --- /dev/null +++ b/db/migrate/20250703193928_add_index_to_search_queries.rb @@ -0,0 +1,5 @@ +class AddIndexToSearchQueries < ActiveRecord::Migration[8.1] + def change + add_index :search_queries, %w[ user_id updated_at ], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index ea2c34fc1..7f5461614 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_07_02_211937) do +ActiveRecord::Schema[8.1].define(version: 2025_07_03_193928) do create_table "accesses", force: :cascade do |t| t.datetime "accessed_at" t.integer "collection_id", null: false @@ -315,6 +315,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_07_02_211937) do t.datetime "updated_at", null: false t.integer "user_id", null: false t.index ["user_id", "terms"], name: "index_search_queries_on_user_id_and_terms" + t.index ["user_id", "updated_at"], name: "index_search_queries_on_user_id_and_updated_at", unique: true t.index ["user_id"], name: "index_search_queries_on_user_id" end diff --git a/db/schema_cache.yml b/db/schema_cache.yml index b4653b357..1f58a58a8 100644 --- a/db/schema_cache.yml +++ b/db/schema_cache.yml @@ -2312,6 +2312,23 @@ indexes: nulls_not_distinct: comment: valid: true + - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition + table: search_queries + name: index_search_queries_on_user_id_and_updated_at + unique: true + columns: + - user_id + - updated_at + lengths: {} + orders: {} + opclasses: {} + where: + type: + using: + include: + nulls_not_distinct: + comment: + valid: true search_results: [] sessions: - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition @@ -2499,4 +2516,4 @@ indexes: comment: valid: true workflows: [] -version: 20250702211937 +version: 20250703193928 diff --git a/script/remove_duplicated_search_queries.rb b/script/remove_duplicated_search_queries.rb new file mode 100755 index 000000000..b810d5286 --- /dev/null +++ b/script/remove_duplicated_search_queries.rb @@ -0,0 +1,19 @@ +#!/usr/bin/env ruby + +require_relative "../config/environment" + +ApplicationRecord.with_each_tenant do |tenant| + User.find_each do |user| + search_queries = Set.new + to_delete = [] + user.search_queries.find_each do |search_query| + if search_queries.include?(search_query.terms) + to_delete << search_query + end + + search_queries << search_query.terms + end + + to_delete.each(&:destroy) + end +end diff --git a/test/models/command/ai/parser_test.rb b/test/models/command/ai/parser_test.rb index 7416d6912..821edd965 100644 --- a/test/models/command/ai/parser_test.rb +++ b/test/models/command/ai/parser_test.rb @@ -4,10 +4,11 @@ class Command::Ai::ParserTest < ActionDispatch::IntegrationTest include CommandTestHelper, VcrTestHelper test "parse command strings into a composite command containing the individual commands" do - result = parse_command "assign @kevin and close" + command = parse_command "assign @kevin and close" - assert_instance_of Command::Composite, result - commands = result.commands + assert_equal "assign @kevin and close", command.line + assert_instance_of Command::Composite, command + commands = command.commands assert_instance_of Command::Assign, commands.first assert_instance_of Command::Close, commands.last diff --git a/test/models/concerns/mentions_test.rb b/test/models/concerns/mentions_test.rb index 006d77ec0..941aed171 100644 --- a/test/models/concerns/mentions_test.rb +++ b/test/models/concerns/mentions_test.rb @@ -5,20 +5,75 @@ class MentionsTest < ActiveSupport::TestCase Current.session = sessions(:david) end - test "create mentions from plain text mentions" do - assert_difference -> { Mention.count }, +1 do + test "don't create mentions when creating or updating drafts" do + assert_no_difference -> { Mention.count } do perform_enqueued_jobs only: Mention::CreateJob do - collections(:writebook).cards.create title: "Cleanup", description: "Did you finish up with the cleanup, @david?" + card = collections(:writebook).cards.create title: "Cleanup", description: "Did you finish up with the cleanup, @david?" + card.update description: "Any thoughts here @jz" end end end - test "create mentions from rich text mentions" do - assert_difference -> { Mention.count }, +1 do - perform_enqueued_jobs only: Mention::CreateJob do + test "create mentions from plain text mentions when publishing cards" do + perform_enqueued_jobs only: Mention::CreateJob do + card = assert_no_difference -> { Mention.count } do + collections(:writebook).cards.create title: "Cleanup", description: "Did you finish up with the cleanup, @david?" + end + + assert_difference -> { Mention.count }, +1 do + card.published! + end + end + end + + test "create mentions from rich text mentions when publishing cards" do + perform_enqueued_jobs only: Mention::CreateJob do + card = assert_no_difference -> { Mention.count } do attachment = ActionText::Attachment.from_attachable(users(:david)) collections(:writebook).cards.create title: "Cleanup", description: "Did you finish up with the cleanup, #{attachment.to_html}?" end + + assert_difference -> { Mention.count }, +1 do + card.published! + end + end + end + + test "don't create repeated mentions when updating cards" do + perform_enqueued_jobs only: Mention::CreateJob do + card = collections(:writebook).cards.create title: "Cleanup", description: "Did you finish up with the cleanup, @david?" + + assert_difference -> { Mention.count }, +1 do + card.published! + end + + assert_no_difference -> { Mention.count } do + card.update description: "Any thoughts here @david" + end + + assert_difference -> { Mention.count }, +1 do + card.update description: "Any thoughts here @jz" + end + end + end + + test "create mentions from plain text mentions when posting comments" do + perform_enqueued_jobs only: Mention::CreateJob do + card = collections(:writebook).cards.create title: "Cleanup", description: "Some initial content", status: :published + + assert_difference -> { Mention.count }, +1 do + card.comments.create!(body: "Great work on this @david!") + end + end + end + + test "don't create mentions from comments when belonging to unpublished cards" do + perform_enqueued_jobs only: Mention::CreateJob do + card = collections(:writebook).cards.create title: "Cleanup", description: "Some initial content" + + assert_no_difference -> { Mention.count } do + card.comments.create!(body: "Great work on this @david!") + end end end @@ -37,6 +92,7 @@ class MentionsTest < ActiveSupport::TestCase 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?" + card.published! assert card.watchers.include?(users(:kevin)) end end diff --git a/test/models/user/searcher_test.rb b/test/models/user/searcher_test.rb index 70e2a4914..35a95cd48 100644 --- a/test/models/user/searcher_test.rb +++ b/test/models/user/searcher_test.rb @@ -13,11 +13,16 @@ class User::SearcherTest < ActiveSupport::TestCase assert_equal "broken", @user.search_queries.last.terms end - test "don't duplicate repeated searches" do - @user.remember_search("broken") + test "don't duplicate repeated searches but touch the existing match" do + search_result = @user.remember_search("broken") + original_updated_at = search_result.updated_at + + travel_to 1.day.from_now assert_no_difference -> { @user.search_queries.count }, +1 do @user.remember_search("broken") end + + assert search_result.reload.updated_at > original_updated_at end end