Merge pull request #629 from basecamp/prompts-init

Prompts for mentions and links
This commit is contained in:
Jorge Manrubia
2025-06-20 14:34:50 +02:00
committed by GitHub
22 changed files with 375 additions and 123 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
GIT
remote: https://github.com/basecamp/actiontext-lexical
revision: 3f1ec3916f468ac2bb626091bcb97469c2c52483
revision: ea4a511a27db4718fdd7db61d7235c3a374cb1ab
specs:
actiontext-lexical (0.1.0)
rails (>= 8.0.2)
+168 -109
View File
@@ -1,136 +1,195 @@
lexical-editor {
.node--selected {
&:not(:has(img)) {
box-shadow: 0 0 0 var(--hover-size) var(--hover-color);
@layer components {
lexical-editor {
display: block;
position: relative;
overflow: visible;
.node--selected:not(.attachment--custom) {
&:not(:has(img)) {
box-shadow: 0 0 0 var(--hover-size) var(--hover-color);
}
&:has(img) {
img {
box-shadow: 0 0 0 var(--hover-size) var(--hover-color);
}
}
}
&:has(img) {
img {
box-shadow: 0 0 0 var(--hover-size) var(--hover-color);
&.lexical-editor--empty {
.lexical-editor__content::before {
content: attr(placeholder);
color: currentColor;
cursor: text;
opacity: 0.66;
pointer-events: none;
position: absolute;
white-space: pre-line;
}
}
}
&.lexical-editor--empty {
.lexical-editor__content::before {
content: attr(placeholder);
color: currentColor;
cursor: text;
opacity: 0.66;
pointer-events: none;
position: absolute;
white-space: pre-line;
.lexical-dialog-actions {
display: flex;
font-size: var(--text-x-small);
flex: 1 1 0%;
gap: var(--inline-space-half);
margin-block-start: var(--block-space-half);
.btn {
--radius: 0.3em;
inline-size: 100%;
justify-content: center;
&:is([type="submit"]) {
--btn-background: var(--card-color, var(--color-link));
--btn-color: var(--color-ink-inverted);
--outline-color: var(--card-color, var(--color-link));
}
}
}
}
.lexical-dialog-actions {
display: flex;
font-size: var(--text-x-small);
flex: 1 1 0%;
gap: var(--inline-space-half);
margin-block-start: var(--block-space-half);
.btn {
--radius: 0.3em;
inline-size: 100%;
justify-content: center;
&:is([type="submit"]) {
--btn-background: var(--card-color, var(--color-link));
--btn-color: var(--color-ink-inverted);
--outline-color: var(--card-color, var(--color-link));
span {
inline-size: 100%;
}
}
span {
inline-size: 100%;
}
}
.lexical-editor__content {
--outline-size: max(2px, 0.08em);
.lexical-editor__content {
--outline-size: max(2px, 0.08em);
margin: var(--block-space-half) 0;
min-block-size: calc(7lh + var(--block-space));
outline: 0;
padding: var(--block-space-half) var(--inline-space);
margin: var(--block-space-half) 0;
min-block-size: calc(7lh + var(--block-space));
outline: 0;
padding: var(--block-space-half) var(--inline-space);
* {
&:first-child {
margin-block-start: 0;
}
* {
&:first-child {
margin-block-start: 0;
}
&:last-child {
margin-block-end: 0;
}
}
}
.lexical-editor--drag-over {
background-color: var(--color-selected);
border-radius: 4px;
outline: 2px dashed var(--color-selected-dark);
}
lexical-toolbar {
background-color: inherit;
border-block-end: 1px solid var(--color-ink-light);
color: currentColor;
display: flex;
font-size: inherit;
margin: 0;
max-inline-size: 100%;
padding: 0.2em 0;
position: relative;
dialog {
background-color: var(--color-canvas);
border: 1px solid var(--color-ink-lighter);
border-radius: 0.5em;
box-shadow: var(--shadow);
color: var(--color-ink);
padding: var(--block-space) calc(var(--inline-space) * 1.5);
position: absolute;
z-index: 1;
.input[type="url"] {
min-inline-size: 30ch;
&:last-child {
margin-block-end: 0;
}
}
}
> button {
aspect-ratio: 4/3.5;
appearance: none;
background-color: transparent;
block-size: 2em;
border: none;
border-radius: 0.2em;
.lexical-editor--drag-over {
background-color: var(--color-selected);
border-radius: 4px;
outline: 2px dashed var(--color-selected-dark);
}
lexical-toolbar {
background-color: inherit;
border-block-end: 1px solid var(--color-ink-light);
color: currentColor;
cursor: pointer;
display: grid;
display: flex;
font-size: inherit;
inline-size: auto;
place-items: center;
transition: background-color 300ms ease;
margin: 0;
max-inline-size: 100%;
padding: 0.2em 0;
position: relative;
svg {
-webkit-touch-callout: none;
block-size: 0.85em;
fill: currentColor;
grid-area: 1/1;
dialog {
background-color: var(--color-canvas);
border: 1px solid var(--color-ink-lighter);
border-radius: 0.5em;
box-shadow: var(--shadow);
color: var(--color-ink);
padding: var(--block-space) calc(var(--inline-space) * 1.5);
position: absolute;
z-index: 1;
.input[type="url"] {
min-inline-size: 30ch;
}
}
> button {
aspect-ratio: 4/3.5;
appearance: none;
background-color: transparent;
block-size: 2em;
border: none;
border-radius: 0.2em;
color: currentColor;
cursor: pointer;
display: grid;
font-size: inherit;
inline-size: auto;
user-select: none;
}
place-items: center;
transition: background-color 300ms ease;
&:is(:focus, :hover) {
background-color: var(--color-ink-lighter);
box-shadow: none;
}
svg {
-webkit-touch-callout: none;
block-size: 0.85em;
fill: currentColor;
grid-area: 1/1;
inline-size: auto;
user-select: none;
}
&:is(:active) {
&:is(:focus, :hover) {
background-color: var(--color-ink-lighter);
box-shadow: none;
}
&:is(:active) {
background-color: var(--color-selected);
}
}
}
/* Prompt
/* ------------------------------------------------------------------------ */
.lexical-prompt-menu {
--lexical-prompt-avatar-size: 24px;
--lexical-prompt-min-width: 20ch;
--lexical-prompt-padding: 0.5ch;
background-color: var(--color-canvas);
border-radius: calc(var(--lexical-prompt-padding) * 2);
box-shadow: var(--shadow);
font-size: var(--text-small);
list-style: none;
margin: 0;
min-inline-size: var(--lexical-prompt-min-width);
padding: var(--lexical-prompt-padding);
visibility: hidden;
z-index: var(--z-popup);
}
.lexical-prompt-menu--visible {
visibility: initial;
}
.lexical-prompt-menu__item {
align-items: center;
border-radius: 0.5ch;
cursor: pointer;
display: flex;
gap: var(--lexical-prompt-padding);
padding: var(--lexical-prompt-padding);
&:hover {
background-color: var(--color-selected);
}
&[aria-selected] {
box-shadow: 0 0 0 var(--hover-size) var(--color-selected-dark) inset;
&:hover {
--hover-size: 0;
}
}
img {
block-size: var(--lexical-prompt-avatar-size);
border-radius: 50%;
flex-shrink: 0;
inline-size: var(--lexical-prompt-avatar-size);
margin: 0;
}
}
}
+42 -3
View File
@@ -20,7 +20,7 @@
:where(h5) { font-size: 0.83em; }
:where(h6) { font-size: 0.67em; }
:where(p, ul, ol, dl, blockquote, figure, .attachment) {
:where(p, ul, ol, dl, blockquote, figure:not(.attachment--custom), .attachment:not(.attachment--custom)) {
margin-block: var(--block-margin);
overflow-wrap: break-word;
text-wrap: pretty;
@@ -54,7 +54,7 @@
}
/* Attachments */
.attachment {
.attachment:not(.attachment--custom) {
block-size: auto;
display: inline-block;
inline-size: 100%;
@@ -221,7 +221,7 @@
}
/* Attachment junk gets wrapped in a paragraph causing unwanted space */
p:has(action-text-attachment) {
p:has(action-text-attachment[src]) {
display: none;
+ * {
@@ -329,5 +329,44 @@
}
}
}
/* Custom attachments such as mentions, etc. */
.attachment--custom {
display: inline;
padding: 0;
margin: 0;
white-space: normal;
}
.attachment--custom,
action-text-attachment {
--attachment-image-size: 1em;
--attachment-selected-color: oklch(var(--lch-blue-dark));
align-items: center;
display: inline-flex;
gap: 0.25ch;
position: relative;
vertical-align: bottom;
lexical-editor & {
cursor: pointer;
}
img {
block-size: var(--attachment-image-size);
border-radius: 50%;
inline-size: var(--attachment-image-size);
}
&.node--selected {
background: var(--attachment-selected-color);
box-shadow:
-0.25ch 0 0 var(--attachment-selected-color),
0.5ch 0 0 var(--attachment-selected-color);
border-radius: 99rem;
color: var(--color-ink-inverted);
}
}
}
}
@@ -0,0 +1,32 @@
class Prompts::CardsController < ApplicationController
MAX_RESULTS = 10
def index
@cards = if filter_param.present?
prepending_exact_matches_by_id(published_cards.mentioning(params[:filter]))
else
@cards = published_cards.latest
end
if stale? etag: @cards
render layout: false
end
end
private
def filter_param
params[:filter]
end
def published_cards
Current.user.accessible_cards.published.limit(MAX_RESULTS)
end
def prepending_exact_matches_by_id(cards)
if card_by_id = Current.user.accessible_cards.find_by_id(params[:filter])
[ card_by_id ] + cards
else
cards
end
end
end
@@ -0,0 +1,11 @@
class Prompts::Collections::UsersController < ApplicationController
include CollectionScoped
def index
@users = @collection.users
if stale? etag: @users
render layout: false
end
end
end
+9
View File
@@ -0,0 +1,9 @@
module RichTextHelper
def mentions_prompt(collection)
content_tag "lexical-prompt", "", trigger: "@", src: prompts_collection_users_path(collection), name: "mention"
end
def cards_prompt
content_tag "lexical-prompt", "", trigger: "#", src: prompts_cards_path, name: "card", "insert-editable-text": true, "remote-filtering": true
end
end
+12
View File
@@ -19,6 +19,10 @@ module Mentions
private
def scan_mentionees
mentionees_from_plain_text | mentionees_from_rich_text
end
def mentionees_from_plain_text
scan_mentioned_handles.filter_map do |mention|
mentionable_users.find { |user| user.mentionable_handles.include?(mention) }
end
@@ -28,6 +32,14 @@ module Mentions
mentionable_content.scan(/(?<!\w)@(\w+)/).flatten.uniq(&:downcase)
end
def mentionees_from_rich_text
mentionees_from_attachments & mentionable_users
end
def mentionees_from_attachments
rich_text_associations.flat_map { send(it.name)&.body&.attachments&.collect { it.attachable } }.compact
end
def mentionable_users
collection.users
end
+1 -1
View File
@@ -1,5 +1,5 @@
class User < ApplicationRecord
include Accessor, Assignee, Mentionable, Named, Role, Transferable
include Accessor, ActionText::Attachable, Assignee, Mentionable, Named, Role, Transferable
include Timelined # Depends on Accessor
has_one_attached :avatar
+9
View File
@@ -3,6 +3,11 @@ module User::Mentionable
included do
has_many :mentions, dependent: :destroy, inverse_of: :mentionee
# Need to set in the included block so that it overrides Action Text's
def to_attachable_partial_path
"users/attachable"
end
end
def mentioned_by(mentioner, at:)
@@ -13,6 +18,10 @@ module User::Mentionable
[ initials, first_name, first_name_with_last_name_initial ].collect(&:downcase)
end
def content_type
"application/vnd.actiontext.mention"
end
private
def first_name_with_last_name_initial
"#{first_name}#{last_name&.first}"
+6 -3
View File
@@ -7,10 +7,13 @@
<div class="comment__body rich-text-content txt-align-start">
<%= form_with model: Comment.new, url: card_comments_path(card), class: "flex flex-column gap full-width",
data: { controller: "form local-save",
local_save_key_value: "comment-#{card.id}",
action: "turbo:submit-end->local-save#submit keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" } do |form| %>
local_save_key_value: "comment-#{card.id}",
action: "turbo:submit-end->local-save#submit keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" } do |form| %>
<%= form.rich_textarea :body, required: true, placeholder: new_comment_placeholder(card),
data: { local_save_target: "input", action: "actiontext:change->local-save#save turbo:morph-element->local-save#restoreContent" } %>
data: { local_save_target: "input", action: "actiontext:change->local-save#save turbo:morph-element->local-save#restoreContent" } do %>
<%= mentions_prompt(@card.collection) %>
<%= cards_prompt %>
<% end %>
<%= form.button class: "comment__submit btn btn--reversed flex-item-justify-start" do %>
<span>Post this comment</span>
<% end %>
+5 -2
View File
@@ -8,7 +8,10 @@
<div class="comment__body rich-text-content txt-align-start">
<%= form_with model: [ @card, @comment ], class: "flex flex-column gap full-width",
data: { controller: "form", action: "keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" } do |form| %>
<%= form.rich_textarea :body, required: true, autofocus: true, placeholder: new_comment_placeholder(@card) %>
<%= form.rich_textarea :body, required: true, autofocus: true, placeholder: new_comment_placeholder(@card) do %>
<%= mentions_prompt(@card.collection) %>
<%= cards_prompt %>
<% end %>
<div class="flex gap-half justify-start">
<%= form.button class: "btn btn--reversed", type: :submit do %>
<span>Save changes</span>
@@ -18,7 +21,7 @@
<% end %>
<%= tag.button type: :submit, class: "btn btn--negative flex-item-justify-end", form: dom_id(@comment, :delete_form),
data: { turbo_confirm: "Are you sure you want to delete this comment?" } do %>
data: { turbo_confirm: "Are you sure you want to delete this comment?" } do %>
<%= icon_tag "trash" %>
<span class="for-screen-reader">Delete</span>
<% end %>
+4 -1
View File
@@ -20,7 +20,10 @@
<div class="card__description rich-text-content margin-block-start-half margin-block-end">
<%= form.rich_textarea :description, class: "card-field__description",
placeholder: "Add some notes, context, pictures, or video about this…",
data: { action: "actiontext: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" } %>
data: { action: "actiontext: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" } do %>
<%= mentions_prompt(card.collection) %>
<%= cards_prompt %>
<% end %>
</div>
<% end %>
<% end %>
+4 -1
View File
@@ -10,7 +10,10 @@
<%= form.rich_textarea :description, class: "card-field__description rich-text-content",
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" } %>
data: { action: "keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" } do %>
<%= mentions_prompt(@card.collection) %>
<%= cards_prompt %>
<% end %>
</div>
<%= form.button "Save changes", type: :submit, class: "btn btn--reversed", style: "--btn-background: #{@card.color}" %>
<%= link_to "Close editor and discard changes", collection_card_path(@card.collection, @card), data: { form_target: "cancel" }, hidden: true %>
+8
View File
@@ -0,0 +1,8 @@
<lexical-prompt-item>
<template type="menu">
#<%= card.id %> <%= card.title %>
</template>
<template type="editor">
<%= link_to "##{card.id} #{card.title}", collection_card_url(card.collection, card) %>
</template>
</lexical-prompt-item>
+1
View File
@@ -0,0 +1 @@
<%= render partial: "prompts/cards/card", collection: @cards %>
@@ -0,0 +1,10 @@
<lexical-prompt-item search="<%= "#{user.name} #{user.initials}" %>" sgid="<%= user.attachable_sgid %>">
<template type="menu">
<%= avatar_image_tag user %>
<%= user.name %>
</template>
<template type="editor">
<%= avatar_image_tag user %>
<%= user.first_name %>
</template>
</lexical-prompt-item>
@@ -0,0 +1 @@
<%= render partial: "prompts/collections/users/user", collection: @users %>
+2
View File
@@ -0,0 +1,2 @@
<%= avatar_image_tag user %>
<%= user.first_name %>
+9
View File
@@ -103,6 +103,15 @@ Rails.application.routes.draw do
resources :pins
end
namespace :prompts do
resources :cards
resources :collections do
scope module: :collections do
resources :users
end
end
end
namespace :public do
resources :collections do
scope module: :collections do
Generated
+1
View File
@@ -324,6 +324,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_06_09_102553) 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|
+17 -1
View File
@@ -2200,7 +2200,23 @@ 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
+22 -1
View File
@@ -5,7 +5,7 @@ class MentionsTest < ActiveSupport::TestCase
Current.session = sessions(:david)
end
test "create mentions when creating messages" do
test "create mentions from plain text mentions" 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?"
@@ -13,6 +13,27 @@ class MentionsTest < ActiveSupport::TestCase
end
end
test "create mentions from rich text mentions" do
assert_difference -> { Mention.count }, +1 do
perform_enqueued_jobs only: Mention::CreateJob 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
end
end
test "can't mention users that don't have access to the collection" do
collections(:writebook).update! all_access: false
collections(:writebook).accesses.revoke_from(users(:david))
assert_no_difference -> { Mention.count }, +1 do
perform_enqueued_jobs only: Mention::CreateJob 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
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?"