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