Merge branch 'main' into fizzy-do-prompts

* main: (57 commits)
  Untenanted access in authenticated controllers should request auth
  dep: bundle update
  dep: update Rails
  dep: bump AR::Tenanted
  Clean up the cable meta tag
  Fix activity feed pagination to fetch on demand
  Create a "published" event if a card is created published
  Scatter Honcho seeds over a 30 day period
  Fix the PWA manifest to use slugged URLs
  Collection publication edits render a turbo frame
  Collection workflow edits render a turbo frame
  Collection entropy config edits render a turbo frame
  Use automatic sizing where supported
  Introduce a separate controller for collection entropy config
  Add `card_id` to index
  Move to scope and inline method
  Prefer modern syntax
  Fix that stage should be visible above card background
  Indicate opens menu
  Hide dialog targets from screen readers when closed
  ...

# Conflicts:
#	db/schema.rb
#	db/schema_cache.yml
This commit is contained in:
Jorge Manrubia
2025-07-03 16:11:00 +02:00
98 changed files with 991 additions and 402 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ gem "stimulus-rails"
gem "turbo-rails"
# Deployment and drivers
gem "active_record-tenanted", bc: "active_record-tenanted"
gem "active_record-tenanted", bc: "active_record-tenanted", branch: "flavorjones/path-based-tenanting"
gem "bootsnap", require: false
gem "kamal", bc: "kamal", ref: "344e2d79", require: false
gem "puma", ">= 5.0"
+30 -29
View File
@@ -7,7 +7,8 @@ GIT
GIT
remote: https://github.com/basecamp/active_record-tenanted
revision: 81a4d02a363fa749de517c90e19588365f934913
revision: 8e33088cdbd69ab7b4cf4100d4202a54aa97e4c0
branch: flavorjones/path-based-tenanting
specs:
active_record-tenanted (0.1.0)
activerecord (>= 8.1.alpha)
@@ -17,9 +18,9 @@ GIT
GIT
remote: https://github.com/basecamp/file_repository
revision: 17e90fc35ccb9f0a0dae1ed8623f01386696002d
revision: 8c8d1594f3ec7e8c0938e6a7f7def521692344e4
specs:
file_repository (1.4.5)
file_repository (1.4.6)
GIT
remote: https://github.com/basecamp/kamal
@@ -40,7 +41,7 @@ GIT
GIT
remote: https://github.com/basecamp/portfolio
revision: 26dd0ccbe4aad5a5c539f90c00e82b6175443636
revision: 34b48dd3c6dc78abd162ef26481971a228084d04
specs:
portfolio (5.2.0)
activesupport
@@ -81,7 +82,7 @@ GIT
GIT
remote: https://github.com/basecamp/signal_id
revision: 8908b2550ff55325b04c108146e00b4408a64e93
revision: 32758d0bff9f17e938905ff5bec53fff359e21b0
branch: rails4
specs:
signal_id (4.3.2)
@@ -111,7 +112,7 @@ GIT
GIT
remote: https://github.com/rails/rails.git
revision: 53a9a3181a59f4e6dff73ba9453734edd7f7e076
revision: 58a2187f7b2ac391eba841e9ec6bb1a6d4e486dd
branch: main
specs:
actioncable (8.1.0.alpha)
@@ -226,18 +227,18 @@ GEM
public_suffix (>= 2.0.2, < 7.0)
ast (2.4.3)
aws-eventstream (1.4.0)
aws-partitions (1.1119.0)
aws-sdk-core (3.226.0)
aws-partitions (1.1124.0)
aws-sdk-core (3.226.2)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.105.0)
aws-sdk-kms (1.106.0)
aws-sdk-core (~> 3, >= 3.225.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.190.0)
aws-sdk-s3 (1.192.0)
aws-sdk-core (~> 3, >= 3.225.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
@@ -290,11 +291,11 @@ GEM
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-multipart (1.1.0)
faraday-multipart (1.1.1)
multipart-post (~> 2.0)
faraday-net_http (3.4.0)
faraday-net_http (3.4.1)
net-http (>= 0.5.0)
faraday-retry (2.3.1)
faraday-retry (2.3.2)
faraday (~> 2.0)
ffi (1.17.2)
ffi (1.17.2-arm64-darwin)
@@ -330,7 +331,7 @@ GEM
activesupport (>= 5.0.0)
jmespath (1.6.2)
json (2.12.2)
jwt (2.10.1)
jwt (3.1.2)
base64
language_server-protocol (3.17.0.5)
lint_roller (1.1.0)
@@ -344,7 +345,7 @@ GEM
net-pop
net-smtp
marcel (1.0.4)
matrix (0.4.2)
matrix (0.4.3)
mini_magick (5.2.0)
benchmark
logger
@@ -393,7 +394,7 @@ GEM
racc (~> 1.4)
nokogiri (1.18.8-x86_64-linux-gnu)
racc (~> 1.4)
ostruct (0.6.1)
ostruct (0.6.2)
parallel (1.27.0)
parser (3.3.8.0)
ast (~> 2.4.1)
@@ -456,7 +457,7 @@ GEM
chunky_png (~> 1.0)
rqrcode_core (~> 2.0)
rqrcode_core (2.0.0)
rubocop (1.75.8)
rubocop (1.77.0)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@@ -464,10 +465,10 @@ GEM
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.44.0, < 2.0)
rubocop-ast (>= 1.45.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.44.1)
rubocop-ast (1.45.1)
parser (>= 3.3.7.2)
prism (~> 1.4)
rubocop-performance (1.25.0)
@@ -485,25 +486,25 @@ GEM
rubocop-performance (>= 1.24)
rubocop-rails (>= 2.30)
ruby-progressbar (1.13.0)
ruby-vips (2.2.3)
ruby-vips (2.2.4)
ffi (~> 1.12)
logger
ruby2_keywords (0.0.5)
rubyzip (2.4.1)
securerandom (0.4.1)
selenium-webdriver (4.33.0)
selenium-webdriver (4.34.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
sentry-rails (5.25.0)
sentry-rails (5.26.0)
railties (>= 5.0)
sentry-ruby (~> 5.25.0)
sentry-ruby (5.25.0)
sentry-ruby (~> 5.26.0)
sentry-ruby (5.26.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
solid_cable (3.0.8)
solid_cable (3.0.11)
actioncable (>= 7.2)
activejob (>= 7.2)
activerecord (>= 7.2)
@@ -519,11 +520,11 @@ GEM
fugit (~> 1.11.0)
railties (>= 7.1)
thor (~> 1.3.1)
sqlite3 (2.7.0)
sqlite3 (2.7.1)
mini_portile2 (~> 2.8.0)
sqlite3 (2.7.0-arm64-darwin)
sqlite3 (2.7.0-x86_64-darwin)
sqlite3 (2.7.0-x86_64-linux-gnu)
sqlite3 (2.7.1-arm64-darwin)
sqlite3 (2.7.1-x86_64-darwin)
sqlite3 (2.7.1-x86_64-linux-gnu)
sshkit (1.24.0)
base64
logger
+6 -2
View File
@@ -85,7 +85,7 @@
.card__background {
.card:has(& img:not([src=""])) {
.card__body,
.card__content,
.card__meta,
.workflow-stage:not(.workflow-stage--current) {
opacity: 0;
@@ -108,6 +108,10 @@
}
}
}
.card__steps {
display: flex;
}
}
/* Column elements
@@ -197,7 +201,7 @@
.card__header {
color: color-mix(in srgb, var(--card-color) 40%, var(--color-ink));
margin-inline-start: calc(-0.5 * var(--card-padding-inline));
margin-inline: calc(-0.5 * var(--card-padding-inline)) calc(-0.25 * var(--card-padding-inline));
}
.card__people-label {
+2 -2
View File
@@ -119,8 +119,8 @@
flex-wrap: nowrap;
gap: var(--inline-space);
margin-block-start: calc(-1 * var(--card-padding-block));
margin-inline-start: calc(-1 * var(--card-padding-inline));
max-inline-size: 100%;
margin-inline: calc(-1 * var(--card-padding-inline)) calc(-0.5 * var(--card-padding-inline));
max-inline-size: unset;
min-inline-size: 0;
.workflow-stage {
+8 -2
View File
@@ -37,8 +37,14 @@
}
.comment__author {
.comment--system & strong {
display: none;
.comment--system & {
h3 {
margin-inline: auto;
}
strong {
display: none;
}
}
.btn {
+1
View File
@@ -30,6 +30,7 @@
.icon--camera { --svg: url("camera.svg "); }
.icon--caret-down { --svg: url("caret-down.svg "); }
.icon--check { --svg: url("check.svg "); }
.icon--check-all { --svg: url("check-all.svg "); }
.icon--clipboard { --svg: url("clipboard.svg "); }
.icon--close { --svg: url("close.svg "); }
.icon--collection { --svg: url("collection.svg "); }
+4 -4
View File
@@ -37,7 +37,6 @@
}
.input--file {
border: 0;
cursor: pointer;
display: grid;
inline-size: auto;
@@ -52,6 +51,7 @@
}
input[type="file"] {
--hover-size: 0;
--input-border-color: transparent;
--input-border-radius: 8px;
@@ -69,9 +69,9 @@
&:focus,
&:focus-visible {
--input-border-color: var(--color-selected-dark);
--input-border-radius: 8px;
--input-border-size: 0.15rem;
.input--file:has(&) {
outline: 0.15rem solid var(--color-selected-dark);
}
}
}
+108
View File
@@ -0,0 +1,108 @@
@layer components {
.step {
display: grid;
grid-template-columns: 1em auto auto;
gap: calc(var(--inline-space) * 2/3);
inline-size: auto;
}
.step__checkbox {
--hover-color: var(--card-color);
appearance: none;
background-color: var(--color-canvas);
block-size: 1.1em;
border: 1px solid currentColor;
border-radius: 0.15em;
color: currentColor;
display: grid;
font: inherit;
inline-size: 1.1em;
margin: 0;
place-content: center;
transform: translateY(0.1em);
&::before {
background-color: CanvasText;
block-size: 0.65em;
box-shadow: inset 1em 1em currentColor;
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
content: "";
inline-size: 0.65em;
transform: scale(0);
transform-origin: center;
transition: 150ms transform ease-in-out;
}
&:checked::before {
transform: scale(1) rotate(10deg);
}
&:where([disabled]):not(:hover):not(:active) {
filter: none;
opacity: 0.5;
}
}
.step__content {
--input-border-radius: 0;
--input-border-size: 0;
--input-padding: 0;
border-bottom: 1px solid transparent;
color: currentColor;
font-weight: 500;
margin-block-end: calc(var(--block-space) * 1/3);
&:is(a, input[type=text]) {
--hover-size: 0;
}
.step:has(:checked) & {
opacity: 0.7;
text-decoration: line-through;
}
&::placeholder {
color: var(--card-color);
}
&:is(input) {
max-inline-size: 70ch;
min-inline-size: 30ch;
@supports (field-sizing: content) {
field-sizing: content;
min-inline-size: 15ch;
}
}
}
.step__content--edit {
border-bottom-color: currentColor;
}
.steps {
contain: inline-size;
display: grid;
list-style: none;
margin: 0;
max-inline-size: 100%;
padding: 0;
}
.steps__icon {
background-color: var(--card-color);
block-size: 1.3em;
border-radius: 50%;
display: grid;
inline-size: 1.3em;
place-content: center;
.icon {
background-color: var(--color-ink-inverted);
block-size: 0.8em;
inline-size: 0.8em;
}
}
}
+1
View File
@@ -315,6 +315,7 @@
.card__meta .btn,
.card__meta-item:not(.card__meta-item--updated),
.card__stages,
.card__steps,
.card__closed {
display: none;
}
+3 -2
View File
@@ -2,8 +2,9 @@ module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
tenanted_connection do
set_current_user || reject_unauthorized_connection
def connect
super
ApplicationRecord.with_tenant(current_tenant) { set_current_user || reject_unauthorized_connection }
end
private
+35
View File
@@ -0,0 +1,35 @@
class Cards::StepsController < ApplicationController
include CardScoped
before_action :set_step, only: %i[ show edit update destroy ]
def create
@step = @card.steps.create!(step_params)
render_card_replacement
end
def show
end
def edit
end
def update
@step.update!(step_params)
render_card_replacement
end
def destroy
@step.destroy!
render_card_replacement
end
private
def set_step
@step = @card.steps.find(params[:id])
end
def step_params
params.expect(step: [ :content, :completed ])
end
end
@@ -0,0 +1,14 @@
class Collections::EntropyConfigurationsController < ApplicationController
include CollectionScoped
def update
@collection.entropy_configuration.update!(entropy_configuration_params)
render turbo_stream: turbo_stream.replace([ @collection, :entropy_configuration ], partial: "collections/edit/auto_close", locals: { collection: @collection })
end
private
def entropy_configuration_params
params.expect(collection: [ :auto_close_period, :auto_reconsider_period ])
end
end
@@ -3,11 +3,12 @@ class Collections::PublicationsController < ApplicationController
def create
@collection.publish
redirect_to edit_collection_path(@collection)
render turbo_stream: turbo_stream.replace([ @collection, :publication ], partial: "collections/edit/publication", locals: { collection: @collection })
end
def destroy
@collection.unpublish
redirect_to edit_collection_path(@collection)
@collection.reload
render turbo_stream: turbo_stream.replace([ @collection, :publication ], partial: "collections/edit/publication", locals: { collection: @collection })
end
end
@@ -5,7 +5,7 @@ class Collections::WorkflowsController < ApplicationController
def update
@collection.update! workflow: @workflow
redirect_to cards_path(collection_ids: [ @collection ])
render turbo_stream: turbo_stream.replace([ @collection, :workflows ], partial: "collections/edit/workflows", locals: { collection: @collection })
end
private
+1 -1
View File
@@ -29,7 +29,7 @@ class CommandsController < ApplicationController
end
def parsing_context
@parsing_context ||= Command::Parser::Context.new(Current.user, url: request.referrer)
@parsing_context ||= Command::Parser::Context.new(Current.user, url: request.referrer, script_name: request.script_name)
end
def confirmed?(command)
+9 -5
View File
@@ -2,6 +2,9 @@ module Authentication
extend ActiveSupport::Concern
included do
# Checking for tenant must happen first so we redirect before trying to access the db.
before_action :require_tenant
before_action :set_current_account
before_action :require_authentication
helper_method :authenticated?
@@ -19,6 +22,7 @@ module Authentication
end
def require_untenanted_access(**options)
skip_before_action :require_tenant, **options
skip_before_action :set_current_account, **options
skip_before_action :require_authentication, **options
before_action :redirect_tenanted_request, **options
@@ -30,6 +34,10 @@ module Authentication
Current.session.present?
end
def require_tenant
ApplicationRecord.current_tenant.present? || request_authentication
end
def require_authentication
resume_session || request_authentication
end
@@ -48,11 +56,7 @@ module Authentication
def request_authentication
session[:return_to_after_authenticating] = request.url
if Rails.application.config.x.local_authentication
redirect_to new_session_path
else
redirect_to Launchpad.login_url(account: Current.account), allow_other_host: true
end
redirect_to Launchpad.login_url(product: true, account: Current.account), allow_other_host: true
end
def after_authentication_url
@@ -1,8 +1,7 @@
class Sessions::LaunchpadController < ApplicationController
require_unauthenticated_access
before_action :store_sig, only: :show
before_action :restore_and_clear_sig, only: :update
before_action :require_sig
def show
end
@@ -17,11 +16,7 @@ class Sessions::LaunchpadController < ApplicationController
end
private
def store_sig
cookies[:_fizzy_launchpad_sig] = params.expect(:sig)
end
def restore_and_clear_sig
@sig = cookies.delete :_fizzy_launchpad_sig
def require_sig
@sig = params.expect(:sig)
end
end
+1 -23
View File
@@ -1,28 +1,6 @@
class SessionsController < ApplicationController
require_unauthenticated_access only: %i[ new create ]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }
before_action :require_first_run_completion
def new
end
def create
if user = User.active.authenticate_by(params.permit(:email_address, :password))
start_new_session_for user
redirect_to after_authentication_url
else
redirect_to new_session_path, alert: "Try another email address or password."
end
end
def destroy
terminate_session
redirect_to new_session_path
redirect_to Launchpad.login_url, allow_other_host: true
end
private
def require_first_run_completion
redirect_to first_run_path if Account.none?
end
end
@@ -1,6 +1,5 @@
class Signup::BaseController < ApplicationController
require_untenanted_access
before_action :redirect_if_local_auth
# TODO: Remove this auth before launch.
http_basic_authenticate_with(
@@ -34,8 +33,4 @@ class Signup::BaseController < ApplicationController
redirect_to account.signal_account.owner.remote_login_url(proceed_to: root_path),
allow_other_host: true
end
def redirect_if_local_auth
render plain: "Unauthorized", status: :unauthorized if Rails.application.config.x.local_authentication
end
end
+1 -1
View File
@@ -37,7 +37,7 @@ module EventsHelper
def event_cluster_tag(hour, col, &)
row = 25 - hour
tag.ul class: "events__time-block", style: "grid-area: #{row}/#{col}", &
tag.div class: "events__time-block", style: "grid-area: #{row}/#{col}", &
end
def event_next_page_link(next_day)
+7
View File
@@ -0,0 +1,7 @@
module TenantingHelper
def tenanted_action_cable_meta_tag
tag "meta",
name: "action-cable-url",
content: "#{request.script_name}#{ActionCable.server.config.mount_path}"
end
end
@@ -6,6 +6,10 @@ export default class extends Controller {
modal: { type: Boolean, default: false }
}
connect() {
this.dialogTarget.setAttribute('aria-hidden', 'true')
}
open() {
const modal = this.modalValue
@@ -14,6 +18,7 @@ export default class extends Controller {
} else {
this.dialogTarget.show()
}
this.dialogTarget.setAttribute('aria-hidden', 'false')
}
toggle() {
@@ -26,6 +31,7 @@ export default class extends Controller {
close() {
this.dialogTarget.close()
this.dialogTarget.setAttribute('aria-hidden', 'true')
this.dialogTarget.blur()
}
+1 -1
View File
@@ -92,7 +92,7 @@ class SQLiteBackupsJob < ApplicationJob
end
def db_path(tenant)
db_config.database
db_config.database_path
end
def db_config
+1 -4
View File
@@ -2,10 +2,7 @@ module Account::SignalAccount
extend ActiveSupport::Concern
included do
unless Rails.application.config.x.local_authentication
# TODO: remove the "optional: true" once we've populated the accounts properly
belongs_to :signal_account, class_name: "SignalId::Account", primary_key: :queenbee_id, foreign_key: :queenbee_id, optional: true
end
belongs_to :signal_account, class_name: "SignalId::Account", primary_key: :queenbee_id, foreign_key: :queenbee_id
end
class_methods do
+1 -1
View File
@@ -1,6 +1,6 @@
class Card < ApplicationRecord
include Assignable, Colored, Engageable, Entropic, Eventable,
Golden, Mentions, Pinnable, Closeable, Readable, Searchable,
Golden, Mentions, Multistep, Pinnable, Closeable, Readable, Searchable,
Staged, Stallable, Statuses, Taggable, Watchable
belongs_to :collection, touch: true
+7
View File
@@ -0,0 +1,7 @@
module Card::Multistep
extend ActiveSupport::Concern
included do
has_many :steps, dependent: :destroy
end
end
+2
View File
@@ -4,6 +4,8 @@ module Card::Statuses
included do
enum :status, %w[ creating drafted published ].index_by(&:itself)
after_create -> { track_event :published }, if: :published?
scope :published_or_drafted_by, ->(user) { where(status: :published).or(where(status: :drafted, creator: user)) }
end
+2 -1
View File
@@ -7,6 +7,7 @@ class Command::Ai::Parser
def initialize(context)
@context = context
self.default_url_options[:script_name] = context.script_name
end
def parse(query)
@@ -71,7 +72,7 @@ class Command::Ai::Parser
def context_from_query(query_json)
if context_properties = query_json[:context].presence
url = cards_path(**context_properties)
Command::Parser::Context.new(user, url: url)
Command::Parser::Context.new(user, url: url, script_name: context.script_name)
end
end
end
+2 -1
View File
@@ -1,7 +1,7 @@
class Command::Parser
attr_reader :context
delegate :user, :cards, :filter, to: :context
delegate :user, :cards, :filter, :script_name, to: :context
def initialize(context)
@context = context
@@ -12,6 +12,7 @@ class Command::Parser
command.user = user
command.line ||= as_plain_text(string)
command.context ||= context
command.default_url_options[:script_name] = script_name
end
end
+5 -3
View File
@@ -1,9 +1,10 @@
class Command::Parser::Context
attr_reader :user, :url
attr_reader :user, :url, :script_name
def initialize(user, url:)
def initialize(user, url:, script_name: "")
@user = user
@url = url
@script_name = script_name
extract_url_components
end
@@ -81,7 +82,8 @@ class Command::Parser::Context
def extract_url_components
uri = URI.parse(url || "")
route = Rails.application.routes.recognize_path(uri.path)
path = uri.path.delete_prefix(script_name)
route = Rails.application.routes.recognize_path(path)
@controller = route[:controller]
@action = route[:action]
@params = ActionController::Parameters.new(Rack::Utils.parse_nested_query(uri.query).merge(route.except(:controller, :action)))
+15 -1
View File
@@ -6,6 +6,20 @@ class Command::VisitUrl < Command
end
def execute
redirect_to url
redirect_to real_url
end
private
def real_url
case url
when String
if url.start_with?(context.script_name)
url
else
[ context&.script_name, url ].compact.join
end
else
url
end
end
end
+9
View File
@@ -0,0 +1,9 @@
class Step < ApplicationRecord
belongs_to :card, touch: true
scope :completed, -> { where(completed: true) }
def completed?
completed
end
end
+1 -3
View File
@@ -2,8 +2,6 @@ module User::SignalUser
extend ActiveSupport::Concern
included do
unless Rails.application.config.x.local_authentication
belongs_to :signal_user, dependent: :destroy, class_name: "SignalId::User", optional: true
end
belongs_to :signal_user, dependent: :destroy, class_name: "SignalId::User", optional: true
end
end
+1 -1
View File
@@ -1,5 +1,5 @@
<li class="flex align-center gap-half pad-inline" data-filter-target="item">
<%= avatar_tag user %>
<%= avatar_tag user, hidden_for_screen_reader: true %>
<strong class="overflow-ellipsis">
<%= link_to user.name, user, class: "txt-ink btn btn--plain" %>
+1 -1
View File
@@ -1,6 +1,6 @@
<div class="panel shadow center flex flex-column">
<strong class="divider txt-large margin-block-end">People on the account</strong>
<%= render "accounts/invite" %>
<%#= render "accounts/invite" # Scheduled work in https://3.basecamp.com/2914079/buckets/37331921/todos/8746162337 %>
<div class="flex flex-column" data-controller="filter">
<div class="pad">
+2
View File
@@ -19,6 +19,8 @@
<%= render "cards/stagings/stages", card: card if card.doing? %>
</div>
<%= render "cards/display/perma/steps", card: card %>
<footer class="card__footer full-width flex align-start gap">
<%= render "cards/display/perma/meta", card: card %>
<%= render "cards/display/perma/background", card: card %>
+9 -7
View File
@@ -8,14 +8,16 @@
<div class="comment__content flex flex-column flex-item-grow full-width">
<div class="comment__author flex align-center gap-half">
<strong>
<%= link_to comment.creator.name, comment.creator, class: "txt-ink btn btn--plain fill-transparent", data: { turbo_frame: "_top" } %>
</strong>
<h3 class="font-weight-normal txt-normal flex-item-justify-start">
<strong>
<%= link_to comment.creator.name, comment.creator, class: "txt-ink btn btn--plain fill-transparent", data: { turbo_frame: "_top" } %>
</strong>
<%= link_to comment, class: "txt-undecorated txt-ink translucent txt-normal txt-capitalize flex-item-justify-start" do %>
<%= local_datetime_tag comment.created_at, style: :agoorweekday %>,
<%= local_datetime_tag comment.created_at, style: :time %>
<% end %>
<%= link_to comment, class: "txt-undecorated txt-ink translucent txt-normal txt-capitalize" do %>
<%= local_datetime_tag comment.created_at, style: :agoorweekday %>,
<%= local_datetime_tag comment.created_at, style: :time %>
<% end %>
</h3>
<%= link_to edit_card_comment_path(comment.card, comment),
class: "comment__edit btn btn--circle borderless fill-transparent translucent" do %>
@@ -1,4 +1,4 @@
<div class="quick-reaction" data-controller="dialog" data-action="keydown.esc->dialog#close click@document->dialog#closeOnClickOutside">
<div class="quick-reaction" data-controller="dialog" data-action="keydown.esc->dialog#close:stop click@document->dialog#closeOnClickOutside">
<%= yield %>
<dialog class="quick-reaction__popup popup panel fill-white shadow" data-dialog-target="dialog">
+1 -1
View File
@@ -7,7 +7,7 @@
<% end %>
</div>
<% else %>
<details class="expander" data-controller="details" data-details-target="details" data-action="keydown.esc->details#close click@document->details#closeOnClickOutside">
<details class="expander" data-controller="details" data-details-target="details" data-action="keydown.esc->details#close:stop click@document->details#closeOnClickOutside">
<summary class="expander__button btn borderless">
Close as
<%= icon_tag "caret-down" %>
+2 -3
View File
@@ -5,13 +5,12 @@
<% end %>
<% elsif !card.closed? %>
<%= form_with model: card, url: collection_card_path(card.collection, card), data: { controller: "form" } do |form| %>
<label class="btn">
<label class="btn input--file">
<%= icon_tag "picture-add" %>
<span class="for-screen-reader">Add background image</span>
<%= form.file_field :image, class: "input",
accept: "image/png, image/jpeg, image/jpg, image/webp",
data: { action: "upload-preview#previewImage form#submit", upload_preview_target: "input" },
hidden: true %>
data: { action: "upload-preview#previewImage form#submit", upload_preview_target: "input" } %>
</label>
<% end %>
<% end %>
+3 -2
View File
@@ -6,6 +6,7 @@
<header class="card__header">
<%= render "cards/display/preview/collection", card: card %>
<%= render "cards/display/preview/tags", card: card %>
<%= render "cards/display/preview/steps", card: card %>
<% if card.staged? && card.doing? %>
<span class="btn justify-start workflow-stage workflow-stage--current txt-uppercase min-width max-width">
@@ -16,9 +17,9 @@
<div class="card__body justify-space-between">
<div class="card__content">
<h1 class="card__title overflow-line-clamp">
<h3 class="card__title overflow-line-clamp">
<%= card.title %>
</h1>
</h3>
<%= link_to collection_card_path(card.collection, card), draggable: false, class: "card__link", title: card_title_tag(card), data: { action: "dialog#close", turbo_frame: "_top" } do %>
<span class="for-screen-reader"><%= card.title %></span>
@@ -0,0 +1,10 @@
<ol class="steps txt-small margin-block">
<%= render partial: "cards/steps/step", collection: card.steps, as: :step %>
<div class="step">
<input type="checkbox" class="step__checkbox" disabled>
<%= form_with model: [card, Step.new], url: card_steps_path(card) do |form| %>
<%= form.text_field :content, class: "input step__content", placeholder: "Add a step…", required: true, autocomplete: "off" %>
<% end %>
</div>
</ol>
@@ -0,0 +1,8 @@
<% if card.steps.any? %>
<div class="card__steps align-center gap-half flex-item-justify-end flex-item-no-shrink">
<span class="steps__icon">
<%= icon_tag "check" %>
</span>
<strong><%= "#{card.steps.completed.count}/#{card.steps.count}" %></strong>
</div>
<% end %>
+2 -2
View File
@@ -7,9 +7,9 @@
<% end %>
<header class="margin-block-end">
<span class="txt-large overflow-ellipsis margin-none font-black" style="view-transistion-name: card-collection-title">
<h1 class="txt-large overflow-ellipsis margin-none font-black" style="view-transistion-name: card-collection-title">
<span><%= filter_title @filter %></span>
</span>
</h1>
<%= render "filters/settings", filter: @filter %>
</header>
+8
View File
@@ -0,0 +1,8 @@
<%= turbo_frame_tag step do %>
<li class="step">
<%= form_with model: [step.card, step], data: { controller: "form" } do |form| %>
<%= form.check_box :completed, { class: "step__checkbox", data: { action: "change->form#submit" } } %>
<% end %>
<%= link_to step.content, edit_card_step_path(step.card, step), class: "step__content" %>
</li>
<% end %>
+18
View File
@@ -0,0 +1,18 @@
<%= turbo_frame_tag @step do %>
<div class="flex align-center gap-half">
<%= form_with model: [@card, @step], class: "step", data: { controller: "form" } do |form| %>
<%= form.check_box :completed, { class: "step__checkbox", checked: @step.completed?, disabled: true } %>
<%= form.text_field :content, class: "input step__content step__content--edit", placeholder: "Name this step…", required: true, autofocus: true, autocomplete: "off",
data: { action: "keydown.esc->form#cancel focus->form#select" } %>
<%= form.button type: "submit", class: "btn btn--positive txt-xx-small" do %>
<%= icon_tag "check" %>
<span class="for-screen-reader">Save changes</span>
<% end %>
<%= link_to "Cancel changes", card_step_path(@card, @step), data: { form_target: "cancel" }, hidden: true %>
<% end %>
<%= button_to card_step_path(@card, @step), method: :delete, class: "btn btn--negative txt-xx-small" do %>
<%= icon_tag "trash" %>
<span class="for-screen-reader">Delete this step</span>
<% end %>
</div>
<% end %>
+3
View File
@@ -0,0 +1,3 @@
<%= turbo_frame_tag @step do %>
<%= render "cards/steps/step", step: @step %>
<% end %>
@@ -1,4 +1,6 @@
<div class="margin-block-end">
<strong class="divider txt-large">Do or Die</strong>
<%= render "entropy/auto_close", model: collection %>
</div>
<%= turbo_frame_tag @collection, :entropy_configuration do %>
<div class="margin-block-end">
<strong class="divider txt-large">Do or Die</strong>
<%= render "entropy/auto_close", model: collection, url: collection_entropy_configuration_path(collection) %>
</div>
<% end %>
@@ -1,54 +1,55 @@
<div class="margin-block">
<strong class="divider txt-large">Public link</strong>
<p class="margin-none-block-start">Turn on the Public link to share this collection with anyone in the world. They wont need to log in and they wont be able to see anything else in Fizzy.</p>
<%= turbo_frame_tag @collection, :publication do %>
<div class="margin-block">
<strong class="divider txt-large">Public link</strong>
<p class="margin-none-block-start">Turn on the Public link to share this collection with anyone in the world. They wont need to log in and they wont be able to see anything else in Fizzy.</p>
<% if collection.published? %>
<div class="border-radius pad fill-selected">
<div class="flex-inline center justify-between gap">
<span class="txt-large"><%= icon_tag "lock" %></span>
<label class="switch flex align-center justify-between">
<%= form_with url: collection_publication_path(collection), method: :delete, data: { controller: "form" } do |form| %>
<%= form.check_box :published, class: "switch__input", checked: true, data: { action: "change->form#submit" } %>
<span class="switch__btn round"></span>
<span class="for-screen-reader">Turn off the public link</span>
<% end %>
</label>
<span class="txt-large"><%= icon_tag "world" %></span>
</div>
<% if collection.published? %>
<div class="border-radius pad fill-selected">
<div class="flex-inline center justify-between gap">
<span class="txt-large"><%= icon_tag "lock" %></span>
<label class="switch flex align-center justify-between">
<%= form_with url: collection_publication_path(collection), method: :delete, data: { controller: "form" } do |form| %>
<%= form.check_box :published, class: "switch__input", checked: true, data: { action: "change->form#submit" } %>
<span class="switch__btn round"></span>
<span class="for-screen-reader">Turn off the public link</span>
<% end %>
</label>
<span class="txt-large"><%= icon_tag "world" %></span>
</div>
<div class="flex align-center gap-half margin-block">
<%= text_field_tag :publication_url, published_collection_url(collection), disabled: true, class: "full-width input fill-white" %>
<div class="flex align-center justify-center gap-half">
<%= button_to_copy_to_clipboard(published_collection_url(collection)) do %>
<%= icon_tag "copy-paste" %>
<span class="for-screen-reader">Copy public link</span>
<div class="flex align-center gap-half margin-block">
<%= text_field_tag :publication_url, published_collection_url(collection), disabled: true, class: "full-width input fill-white" %>
<div class="flex align-center justify-center gap-half">
<%= button_to_copy_to_clipboard(published_collection_url(collection)) do %>
<%= icon_tag "copy-paste" %>
<span class="for-screen-reader">Copy public link</span>
<% end %>
</div>
</div>
<strong class="margin-block-end-half flex justify-center txt-small">Add an optional description to the public page</strong>
<div class="border-radius input fill-white">
<%= form_with model: collection, class: "txt-align-start", data: { controller: "form" } do |form| %>
<%= form.rich_textarea :public_description, class: "rich-text-content txt-small",
placeholder: "Add a public note about this collection…",
data: { action: "keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" } %>
<%= form.button "Save changes", type: :submit, class: "btn txt-small" %>
<% end %>
</div>
</div>
<strong class="margin-block-end-half flex justify-center txt-small">Add an optional description to the public page</strong>
<div class="border-radius input fill-white">
<%= form_with model: collection, class: "txt-align-start", data: { controller: "form" } do |form| %>
<%= form.rich_textarea :public_description, class: "rich-text-content txt-small",
placeholder: "Add a public note about this collection…",
data: { action: "keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" } %>
<%= form.button "Save changes", type: :submit, class: "btn txt-small" %>
<% end %>
<% else %>
<div class="border-radius pad fill-shade">
<div class="flex-inline center justify-between gap">
<span class="txt-large"><%= icon_tag "lock" %></span>
<label class="switch flex align-center justify-between">
<%= form_with url: collection_publication_path(collection), method: :post, data: { controller: "form" } do |form| %>
<%= form.check_box :published, class: "switch__input", checked: false, data: { action: "change->form#submit" } %>
<span class="switch__btn round"></span>
<span class="for-screen-reader">Turn on the public link</span>
<% end %>
</label>
<span class="txt-large"><%= icon_tag "world" %></span>
</div>
</div>
</div>
<% else %>
<div class="border-radius pad fill-shade">
<div class="flex-inline center justify-between gap">
<span class="txt-large"><%= icon_tag "lock" %></span>
<label class="switch flex align-center justify-between">
<%= form_with url: collection_publication_path(collection), method: :post, data: { controller: "form" } do |form| %>
<%= form.check_box :published, class: "switch__input", checked: false, data: { action: "change->form#submit" } %>
<span class="switch__btn round"></span>
<span class="for-screen-reader">Turn on the public link</span>
<% end %>
</label>
<span class="txt-large"><%= icon_tag "world" %></span>
</div>
</div>
<% end %>
</div>
<% end %>
</div>
<% end %>
+23 -21
View File
@@ -1,21 +1,23 @@
<strong class="txt-large">Workflows</strong>
<div class="flex justify-center margin-block-end">Use a Workflow to track progress in this Collection.</div>
<div class="flex flex-wrap gap justify-center">
<% Workflow.all.each do |workflow| %>
<div class="workflow-preview txt-align-start border border-radius <%= 'workflow-preview--selected' if workflow == collection.workflow %>">
<%= button_to collection_workflow_path(collection), method: :patch do %>
<%= hidden_field_tag "collection[workflow_id]", workflow.id %>
<strong class="txt-small overflow-ellipsis flex-item-no-shrink"><%= workflow.name %></strong>
<ul class="list-style-none txt-xx-small margin-none flex flex-column gap-half">
<% workflow.stages.each do |stage| %>
<li class="overflow-ellipsis flex align-center gap-half min-width">
<span class="workflow-preview__swatch btn btn--circle" style="--btn-background: <%= stage.color.present? ? stage.color : Card::Colored::DEFAULT_COLOR %>;"></span>
<span class="overflow-ellipsis"><%= stage.name %></span>
</li>
<% end %>
</ul>
<% end %>
</div>
<% end %>
</div>
<p class="margin-block-end-double"><%= link_to "Manage workflows", workflows_path, class: "btn btn--plain txt-link" %></p>
<%= turbo_frame_tag @collection, :workflows do %>
<strong class="txt-large">Workflows</strong>
<div class="flex justify-center margin-block-end">Use a Workflow to track progress in this Collection.</div>
<div class="flex flex-wrap gap justify-center">
<% Workflow.all.each do |workflow| %>
<div class="workflow-preview txt-align-start border border-radius <%= 'workflow-preview--selected' if workflow == collection.workflow %>">
<%= button_to collection_workflow_path(collection), method: :patch do %>
<%= hidden_field_tag "collection[workflow_id]", workflow.id %>
<strong class="txt-small overflow-ellipsis flex-item-no-shrink"><%= workflow.name %></strong>
<ul class="list-style-none txt-xx-small margin-none flex flex-column gap-half">
<% workflow.stages.each do |stage| %>
<li class="overflow-ellipsis flex align-center gap-half min-width">
<span class="workflow-preview__swatch btn btn--circle" style="--btn-background: <%= stage.color.present? ? stage.color : Card::Colored::DEFAULT_COLOR %>;"></span>
<span class="overflow-ellipsis"><%= stage.name %></span>
</li>
<% end %>
</ul>
<% end %>
</div>
<% end %>
</div>
<p class="margin-block-end-double"><%= link_to "Manage workflows", workflows_path, class: "btn btn--plain txt-link" %></p>
<% end %>
+2 -2
View File
@@ -18,7 +18,7 @@
turbo:submit-end->terminal#handleCommandResponse
"
} do %>
<label class="terminal__label txt-nowrap" for="fizzy_do">Fizzy do &gt;</label>
<label class="terminal__label txt-nowrap" for="command">Fizzy do &gt;</label>
<%= rich_textarea_tag "command", nil,
toolbar: false,
@@ -35,5 +35,5 @@
<%= commands_prompt %>
<% end %>
<%= hidden_field_tag "confirmed", nil, data: { terminal_target: "confirmation" } %>
<%= hidden_field_tag "confirmed", nil, data: { terminal_target: "confirmation" } %>
<% end %>
+2 -2
View File
@@ -6,12 +6,12 @@
related_element_group_value: card.id,
action: "mouseover->related-element#highlight mouseout->related-element#unhighlight" } do %>
<div class="card__header flex align-start gap full-width">
<div class="card__collection flex-inline align-start">
<h4 class="card__collection flex-inline align-start">
<span class="card__id"><%= card.id %></span>
<span class="card__collection-name">
<span class="overflow-ellipsis"><%= event.collection.name %></span>
</span>
</div>
</h4>
<% if event_column(event) == 2 %>
<span class="event__icon flex-item-justify-end txt-reversed">
+1 -1
View File
@@ -36,5 +36,5 @@
</header>
<div class="events" id="activity">
<%= render "events/day", day_timeline: @day_timeline %>
<%= event_next_page_link(@day_timeline.next_day) %>
</div>
<%= event_next_page_link(@day_timeline.next_day) %>
+1 -1
View File
@@ -4,7 +4,7 @@
<body data-controller="lightbox local-time timezone-cookie" data-action="turbo:morph@window->local-time#refreshAll">
<header id="header">
<a href="#main-content" class="skip-navigation btn">Skip to main content</a>
<a href="#main" class="skip-navigation btn" data-turbo="false">Skip to main content</a>
<%= yield :header %>
</header>
+1 -1
View File
@@ -4,7 +4,7 @@
<body class="public" data-controller="local-time timezone-cookie" data-action="turbo:morph@window->local-time#refreshAll">
<header id="header">
<a href="#main-content" class="skip-navigation btn">Skip to main content</a>
<a href="#main" class="skip-navigation btn" data-turbo="false">Skip to main content</a>
<%= yield :header %>
</header>
+2 -1
View File
@@ -15,9 +15,10 @@
<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
<%= tenanted_action_cable_meta_tag %>
<%= yield :head %>
<link rel="manifest" href="/manifest.json">
<link rel="manifest" href="<%= pwa_manifest_path(format: :json) %>">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/favicon.png" type="image/png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
+1 -1
View File
@@ -12,7 +12,7 @@
</button>
</div>
<button class="tray__expander" data-action="dialog#toggle" aria-label="Expand pins stack">
<button class="tray__expander" data-action="dialog#toggle" aria-label="Expand pins stack" aria-haspopup="true">
<%= icon_tag "pinned" %>
</button>
</section>
@@ -10,9 +10,9 @@
<%= avatar_image_tag notification.creator %>
</div>
<div class="flex flex-column min-width flex-item-grow">
<h3 class="flex flex-column min-width flex-item-grow font-weight-normal">
<%= render "notifications/notification/#{notification.source_type.underscore}/body", notification: notification %>
</div>
</h3>
</div>
<% end %>
<% end %>
+2 -2
View File
@@ -16,7 +16,7 @@
<div class="tray__actions">
<%= button_to read_all_notifications_path,
class: "tray__notification-read-action btn btn--reversed fill-transparent borderless flex-item-justify-start txt-xx-small",
title: "Mark all as read",
title: "Mark all notifications as read",
data: { action: "click->dialog#close", turbo_frame: "notifications" } do %>
<%= icon_tag "check" %>
<% end %>
@@ -34,7 +34,7 @@
<% end %>
</div>
<button class="tray__expander" data-action="dialog#toggle" aria-label="Expand notifications stack">
<button class="tray__expander" data-action="dialog#toggle" aria-label="Expand notifications stack" aria-haspopup="true">
<%= icon_tag "bell" %>
</button>
</section>
+3 -3
View File
@@ -18,7 +18,7 @@
"purpose": "maskable"
}
],
"start_url": "/",
"start_url": "<%= root_url %>",
"display": "standalone",
"scope": "/",
"description": "Card-up the biggest issues in your projects",
@@ -27,13 +27,13 @@
{
"name": "Notifications",
"description": "Catch up on recent notifications",
"url": "/notifications",
"url": "<%= notifications_path %>",
"icons": [{ "src": "<%= image_url("bell.svg") %>", "sizes": "any" }]
},
{
"name": "Latest Activity",
"description": "See whats new",
"url": "/",
"url": "<%= root_url %>",
"icons": [{ "src": "<%= image_url("activity.svg") %>", "sizes": "any" }]
}
],
+3 -1
View File
@@ -1 +1,3 @@
<%= auto_submit_form_with method: :put %>
<%= auto_submit_form_with(method: :put) do |form| %>
<%= form.hidden_field(:sig, value: @sig) %>
<% end %>
-30
View File
@@ -1,30 +0,0 @@
<% @page_title = "Sign in" %>
<div class="panel shadow center margin-block-double <%= "shake" if flash[:alert] %>" style="--panel-size: 55ch;">
<h1 class="txt-xx-large margin-block-end-double">Fizzy</h1>
<%= form_with url: session_path, class: "flex flex-column gap txt-large" do |form| %>
<div class="flex align-center gap">
<label class="flex align-center gap input input--actor">
<%= form.email_field :email_address, required: true, class: "input full-width", autofocus: true, autocomplete: "username", placeholder: "Enter your email address" %>
<%= icon_tag "email", class: "txt-large" %>
</label>
</div>
<div class="flex align-center gap">
<label class="flex align-center gap input input--actor">
<%= form.password_field :password, required: true, class: "input full-width", autocomplete: "current-password", placeholder: "Enter your password", maxlength: 72 %>
<%= icon_tag "password", class: "txt-large" %>
</label>
</div>
<button type="submit" id="log_in" class="btn btn--reversed center">
<%= icon_tag "arrow-right" %>
<span class="for-screen-reader">Sign in</span>
</button>
<% end %>
</div>
<% content_for :footer do %>
<div class="txt-align-center center margin-block-double txt-subtle">Fizzy&trade; version 0</div>
<% end %>
+1 -1
View File
@@ -2,7 +2,7 @@
<% content_for :header do %>
<nav>
<%= link_to new_session_path, class: "btn flex-item-justify-end", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %>
<%= link_to Launchpad.login_url(account: Current.account), class: "btn flex-item-justify-end", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %>
<span class="for-screen-reader">Sign in instead</span>
<% end %>
</nav>
+1 -1
View File
@@ -43,7 +43,7 @@
</div>
<div class="panel borderless center margin-block-double">
<%= button_to session_path, method: :delete, class: "btn center txt-medium" do %>
<%= button_to session_path, method: :delete, class: "btn center txt-medium", data: { turbo: false } do %>
<%= icon_tag "logout" %>
<span>Sign out</span>
<% end %>
+3 -2
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env sh
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 "Login with david@37signals.com / secret123456 to:"
echo " - 37signals: http://fizzy.localhost:3006/735464785"
echo " - Honcho: http://fizzy.localhost:3006/175932900"
if [ -f tmp/solid-queue.txt ]; then
export SOLID_QUEUE_IN_PUMA=1
+5
View File
@@ -0,0 +1,5 @@
Rails.application.config.before_initialize do
# We don't want normal tenanted authentication on mission control.
# Note that we're using HTTP basic auth configured via credentials.
MissionControl::Jobs.base_controller_class = "ActionController::Base"
end
-7
View File
@@ -1,18 +1,11 @@
require "signal_id"
Rails.application.config.x.local_authentication = ENV["LOCAL_AUTHENTICATION"].present?
ENV["SIGNAL_ID_SECRET"] = Rails.application.credentials.signal_id_secret
Rails.application.config.to_prepare do
SignalId.product = "fizzy"
db_config = SignalId::Database.default_configuration
if Rails.application.config.x.local_authentication
db_config.each do |name, config|
config["connect_timeout"] = 1
end
end
SignalId::Database.load_configuration db_config
SignalId::Database.enable_rw_splitting!
-21
View File
@@ -1,21 +0,0 @@
Rails.application.config.after_initialize do
# in production and staging, we're using a two-level subdomain like "tenant.fizzy.37signals.com".
# in development and beta it's only a single-level subdomain.
Rails.application.config.active_record_tenanted.tenant_resolver = ->(request) do
tld_length = [ "37signals.com", "37signals-staging.com" ].include?(request.domain) ? 2 : 1
if request.path == "/up"
nil
elsif subdomain = request.subdomain(tld_length).presence
subdomain
elsif request.path =~ %r{^/(queenbee|signup)\b}
nil
else
"return-404" # this is a quick fix for now
end
end
end
ActiveSupport.on_load(:action_controller_base) do
before_action { logger.struct tenant: ApplicationRecord.current_tenant }
end
@@ -0,0 +1,35 @@
module AccountSlug
PATTERN = /(\d{7,})/
FORMAT = "%07d"
PATH_INFO_MATCH = /\A(\/#{AccountSlug::PATTERN})/
# We're using account id prefixes in the URL path. Rather than namespace
# all our routes, we're "mounting" the Rails app at this URL prefix.
def self.extract(request)
# $1, $2, $' == script_name, slug, path_info
if request.script_name && request.script_name =~ PATH_INFO_MATCH
# Likely due to restarting the action cable connection after upgrade
AccountSlug.decode($2)
elsif request.path_info =~ PATH_INFO_MATCH
# Yanks the prefix off PATH_INFO and move it to SCRIPT_NAME
request.engine_script_name = request.script_name = $1
request.path_info = $'.empty? ? "/" : $'
# Limit session cookies to the slug path.
# TODO TEST ME
request.env["rack.session.options"][:path] = $1
# Return the account id for tenanting.
AccountSlug.decode($2)
end
end
def self.decode(slug) slug.to_i end
def self.encode(id) FORMAT % id end
end
Rails.application.config.after_initialize do
Rails.application.config.active_record_tenanted.tenant_resolver = ->(request) do
AccountSlug.extract(request)
end
end
@@ -0,0 +1,19 @@
module ActiveStorageControllerExtensions
extend ActiveSupport::Concern
included do
before_action do
# Add script_name so that Disk Service will generate correct URLs for uploads
ActiveStorage::Current.url_options = {
protocol: request.protocol,
host: request.host,
port: request.port,
script_name: request.script_name
}
end
end
end
Rails.application.config.after_initialize do
ActiveStorage::BaseController.include ActiveStorageControllerExtensions
end
+3
View File
@@ -0,0 +1,3 @@
ActiveSupport.on_load(:action_controller_base) do
before_action { logger.struct tenant: ApplicationRecord.current_tenant }
end
+18
View File
@@ -0,0 +1,18 @@
module TurboStreamsJobExtensions
extend ActiveSupport::Concern
class_methods do
def render_format(format, **rendering)
if ApplicationRecord.current_tenant
script_name = "/#{ApplicationRecord.current_tenant}"
ApplicationController.renderer.new(script_name: script_name).render(formats: [ format ], **rendering)
else
super
end
end
end
end
Rails.application.config.after_initialize do
Turbo::StreamsChannel.prepend TurboStreamsJobExtensions
end
+4 -2
View File
@@ -18,6 +18,7 @@ Rails.application.routes.draw do
resource :workflow, only: :update
resource :involvement
resource :publication
resource :entropy_configuration
end
resources :cards
@@ -44,6 +45,7 @@ Rails.application.routes.draw do
resources :assignments
resources :taggings
resources :steps
resources :comments do
resources :reactions, module: :comments
@@ -84,11 +86,11 @@ Rails.application.routes.draw do
get "join/:join_code", to: "users#new", as: :join
post "join/:join_code", to: "users#create"
resource :session do
resource :session, only: :destroy do
scope module: "sessions" do
resources :transfers, only: %i[ show update ]
resource :launchpad, only: %i[ show update ], controller: "launchpad"
end
resource :launchpad, only: %i[ show update ], controller: "sessions/launchpad"
end
namespace :signup do
@@ -0,0 +1,13 @@
class AddStepsToCards < ActiveRecord::Migration[8.1]
def change
create_table :steps do |t|
t.references :card, null: false, foreign_key: true
t.text :content, null: false
t.boolean :completed, default: false, null: false
t.timestamps
end
add_index :steps, :completed
end
end
@@ -0,0 +1,5 @@
class RemovePasswordFromUsers < ActiveRecord::Migration[8.1]
def change
remove_column :users, :password, :string
end
end
@@ -0,0 +1,5 @@
class AddNotNullToAccountsQueenbeeId < ActiveRecord::Migration[8.1]
def change
change_column_null :accounts, :queenbee_id, false
end
end
@@ -0,0 +1,6 @@
class ChangeStepsIndex < ActiveRecord::Migration[8.1]
def change
remove_index :steps, :completed
add_index :steps, %i[ card_id completed ]
end
end
Generated
+13 -9
View File
@@ -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_06_26_000000) do
ActiveRecord::Schema[8.1].define(version: 2025_07_02_211937) do
create_table "accesses", force: :cascade do |t|
t.datetime "accessed_at"
t.integer "collection_id", null: false
@@ -28,7 +28,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_06_26_000000) do
t.datetime "created_at", null: false
t.string "join_code"
t.string "name", null: false
t.integer "queenbee_id"
t.integer "queenbee_id", null: false
t.datetime "updated_at", null: false
t.index ["queenbee_id"], name: "index_accounts_on_queenbee_id", unique: true
end
@@ -133,13 +133,6 @@ ActiveRecord::Schema[8.1].define(version: 2025_06_26_000000) do
t.index ["stage_id"], name: "index_cards_on_stage_id"
end
create_table "closers_filters", id: false, force: :cascade do |t|
t.integer "closer_id", null: false
t.integer "filter_id", null: false
t.index ["closer_id"], name: "index_closers_filters_on_closer_id"
t.index ["filter_id"], name: "index_closers_filters_on_filter_id"
end
create_table "closure_reasons", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "label"
@@ -332,6 +325,16 @@ ActiveRecord::Schema[8.1].define(version: 2025_06_26_000000) do
t.index ["user_id"], name: "index_sessions_on_user_id"
end
create_table "steps", force: :cascade do |t|
t.integer "card_id", null: false
t.boolean "completed", default: false, null: false
t.text "content", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["card_id", "completed"], name: "index_steps_on_card_id_and_completed"
t.index ["card_id"], name: "index_steps_on_card_id"
end
create_table "taggings", force: :cascade do |t|
t.integer "card_id", null: false
t.datetime "created_at", null: false
@@ -407,6 +410,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_06_26_000000) do
add_foreign_key "pins", "users"
add_foreign_key "search_queries", "users"
add_foreign_key "sessions", "users"
add_foreign_key "steps", "cards"
add_foreign_key "taggings", "cards"
add_foreign_key "taggings", "tags"
add_foreign_key "watches", "cards"
+73 -59
View File
@@ -128,7 +128,7 @@ columns:
name: queenbee_id
cast_type: *3
sql_type_metadata: *4
'null': true
'null': false
default:
default_function:
collation:
@@ -451,7 +451,7 @@ columns:
default_function:
collation:
comment:
- &31 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- &33 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: title
cast_type: *7
@@ -462,18 +462,6 @@ columns:
collation:
comment:
- *9
closers_filters:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: closer_id
cast_type: *3
sql_type_metadata: *4
'null': false
default:
default_function:
collation:
comment:
- *19
closure_reasons:
- *5
- *6
@@ -532,11 +520,11 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: all_access
cast_type: &32 !ruby/object:ActiveModel::Type::Boolean
cast_type: &30 !ruby/object:ActiveModel::Type::Boolean
precision:
scale:
limit:
sql_type_metadata: &33 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata
sql_type_metadata: &31 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata
sql_type: boolean
type: :boolean
limit:
@@ -756,7 +744,7 @@ columns:
comment:
filters_tags:
- *19
- &30 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- &32 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: tag_id
cast_type: *3
@@ -952,23 +940,48 @@ columns:
collation:
comment:
- *25
steps:
- *22
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: completed
cast_type: *30
sql_type_metadata: *31
'null': false
default: false
default_function:
collation:
comment:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: content
cast_type: *15
sql_type_metadata: *16
'null': false
default:
default_function:
collation:
comment:
- *5
- *6
- *9
taggings:
- *22
- *5
- *6
- *30
- *32
- *9
tags:
- *5
- *6
- *31
- *33
- *9
users:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: active
cast_type: *32
sql_type_metadata: *33
cast_type: *30
sql_type_metadata: *31
'null': false
default: true
default_function:
@@ -1027,8 +1040,8 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: watching
cast_type: *32
sql_type_metadata: *33
cast_type: *30
sql_type_metadata: *31
'null': false
default: true
default_function:
@@ -1079,7 +1092,6 @@ primary_keys:
card_engagements: id
card_goldnesses: id
cards: id
closers_filters:
closure_reasons: id
closures: id
collection_publications: id
@@ -1101,6 +1113,7 @@ primary_keys:
search_queries: id
search_results: id
sessions: id
steps: id
taggings: id
tags: id
users: id
@@ -1122,7 +1135,6 @@ data_sources:
card_engagements: true
card_goldnesses: true
cards: true
closers_filters: true
closure_reasons: true
closures: true
collection_publications: true
@@ -1144,6 +1156,7 @@ data_sources:
search_queries: true
search_results: true
sessions: true
steps: true
taggings: true
tags: true
users: true
@@ -1542,39 +1555,6 @@ indexes:
nulls_not_distinct:
comment:
valid: true
closers_filters:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: closers_filters
name: index_closers_filters_on_closer_id
unique: false
columns:
- closer_id
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: closers_filters
name: index_closers_filters_on_filter_id
unique: false
columns:
- filter_id
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
closure_reasons: []
closures:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
@@ -2303,6 +2283,40 @@ indexes:
nulls_not_distinct:
comment:
valid: true
steps:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: steps
name: index_steps_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: steps
name: index_steps_on_card_id_and_completed
unique: false
columns:
- card_id
- completed
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
taggings:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: taggings
@@ -2438,4 +2452,4 @@ indexes:
comment:
valid: true
workflows: []
version: 20250626000000
version: 20250702211937
+4 -1
View File
@@ -1,5 +1,8 @@
raise "Seeding is just for development" unless Rails.env.development?
require "active_support/testing/time_helpers"
include ActiveSupport::Testing::TimeHelpers
# Seed DSL
def seed_account(name)
print " #{name}"
@@ -9,7 +12,7 @@ end
def create_tenant(signal_account_name)
signal_account = SignalId::Account.find_by_product_and_name!("fizzy", signal_account_name)
tenant_name = signal_account.subdomain
tenant_name = signal_account.queenbee_id
ApplicationRecord.destroy_tenant tenant_name
ApplicationRecord.create_tenant(tenant_name) do
+15 -5
View File
@@ -50,19 +50,29 @@ collections = [
"Documentation"
]
time_range = (60 .. 30.days.in_minutes)
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|
travel(-rand(time_range).minutes) do
card = create_card title,
description: "#{title} for #{collection_name} phase #{index + 1}.",
collection: collection,
creator: authors.sample
# Randomly assign to 1-2 authors
travel rand(0..20).minutes
card.toggle_assignment(authors.sample)
card.toggle_assignment(authors.sample) if rand > 0.5
if rand > 0.5
travel rand(0..20).minutes
card.toggle_assignment(authors.sample)
end
# Randomly set card state
travel rand(0..20).minutes
case rand(3)
when 0
card.engage
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env ruby
require_relative "../config/environment"
ApplicationRecord.with_each_tenant do |tenant|
account = Account.first
signal_account = account.signal_account
signal_users = SignalId::User.where(account_id: signal_account.id)
signal_users.each do |signal_user|
unless User.find_by(signal_user_id: signal_user.id)
User.create!(
name: signal_user.identity.name,
email_address: signal_user.identity.email_address,
signal_user_id: signal_user.id,
password: SecureRandom.hex(36) # TODO: remove password column?
)
end
end
end
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env ruby
require_relative "../config/environment"
domains = {
"production" => "fizzy.37signals.com",
"beta" => "fizzy-beta.37signals.com",
"staging" => "fizzy.37signals-staging.com"
}
def fix_attachments(rich_text)
if rich_text.body
rich_text.body.send(:attachment_nodes).each do |node|
sgid = SignedGlobalID.parse(node["sgid"], for: ActionText::Attachable::LOCATOR_NAME)
if sgid
puts "Fixing attachment node: #{node.to_html}"
model = sgid.model_class.find(sgid.model_id)
node["sgid"] = model.attachable_sgid
else
puts "Skipping attachment node without valid sgid: #{node.to_html}"
end
end
rich_text.save!
end
end
ApplicationRecord.with_each_tenant do |tenant|
account_id = Account.first.queenbee_id
unless account_id
puts "Skipping URL fixup for tenant: #{tenant}"
next
end
puts "\n## Processing tenant: #{tenant}\n"
domain = domains[Rails.env] || domains["production"]
regex = %r{://\w+\.#{domain}/}
pp [ Account.first.name, account_id, domain, regex ]
puts
Card.find_each do |card|
puts "### Processing card #{card.id} in #{Rails.application.routes.url_helpers.collection_card_path(card.collection, card)}"
fix_attachments(card.description)
card.reload
old_body = card.description.body.to_s
if match = regex.match(old_body)
puts "URL found in card #{card.id} in #{Rails.application.routes.url_helpers.collection_card_path(card.collection, card)}"
new_body = old_body.gsub(regex, "://#{domain}/#{account_id}/")
card.description.update(body: new_body) || raise("Failed to update card description for card #{card.id}")
end
end
Comment.find_each do |comment|
puts "### Processing comment #{comment.id} in #{Rails.application.routes.url_helpers.collection_card_path(comment.card.collection, comment.card)}"
fix_attachments(comment.body)
comment.reload
old_body = comment.body.body.to_s
if match = regex.match(old_body)
puts "URL found in comment #{comment.id} in #{Rails.application.routes.url_helpers.collection_card_path(comment.card.collection, comment.card)}"
new_body = old_body.gsub(regex, "://#{domain}/#{account_id}/")
comment.body.update(body: new_body) || raise("Failed to update comment body for comment #{comment.id}")
end
end
end
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env ruby
require_relative "../config/environment"
tenant_names = []
ApplicationRecord.with_each_tenant do |tenant|
next if tenant == "#{Rails.env}-tenant"
account = Account.first
queenbee_id = account.queenbee_id
tenant_names << { from: tenant, to: queenbee_id }
ApplicationRecord.remove_connection
end
pp [ "Tenant name changes:", tenant_names ]
root_config = ApplicationRecord.tenanted_root_config
tenant_names.each do |name|
from_db_path = root_config.database_path_for(name[:from])
to_db_path = root_config.database_path_for(name[:to])
from_path = from_db_path.split("/").take(4).join("/")
to_path = to_db_path.split("/").take(4).join("/")
unless from_path == to_path
FileUtils.move from_path, to_path, verbose: true
end
end
puts
pp [ "Tenants after renaming:", ApplicationRecord.tenants ]
@@ -0,0 +1,55 @@
require "test_helper"
class Cards::StepsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :kevin
end
test "create" do
card = cards(:logo)
assert_difference -> { card.steps.count }, +1 do
post card_steps_path(card), params: { step: { content: "Research alternatives" } }, as: :turbo_stream
assert_card_container_rerendered(card)
end
assert_equal "Research alternatives", card.steps.last.content
end
test "update" do
card = cards(:logo)
step = card.steps.create!(content: "Original content")
assert_changes -> { step.reload.content }, from: "Original content", to: "Updated content" do
put card_step_path(card, step), params: { step: { content: "Updated content" } }, as: :turbo_stream
assert_card_container_rerendered(card)
end
end
test "destroy" do
card = cards(:logo)
step = card.steps.create!(content: "Step to delete")
assert_difference -> { card.steps.count }, -1 do
delete card_step_path(card, step), as: :turbo_stream
assert_card_container_rerendered(card)
end
end
test "toggle completion" do
card = cards(:logo)
step = card.steps.create!(content: "Test step", completed: false)
# Toggle to completed
assert_changes -> { step.reload.completed? }, from: false, to: true do
put card_step_path(card, step), params: { step: { completed: "1" } }, as: :turbo_stream
assert_card_container_rerendered(card)
end
# Toggle back to incomplete
assert_changes -> { step.reload.completed? }, from: true, to: false do
put card_step_path(card, step), params: { step: { completed: "0" } }, as: :turbo_stream
assert_card_container_rerendered(card)
end
end
end
@@ -0,0 +1,17 @@
require "test_helper"
class Collections::EntropyConfigurationsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :kevin
@collection = collections(:writebook)
end
test "update" do
put collection_entropy_configuration_path(@collection), params: { collection: { auto_close_period: 1.day, auto_reconsider_period: 2.days } }
assert_equal 1.day, @collection.entropy_configuration.reload.auto_close_period
assert_equal 2.days, @collection.entropy_configuration.reload.auto_reconsider_period
assert_turbo_stream action: :replace, target: dom_id(@collection, :entropy_configuration)
end
end
@@ -13,7 +13,7 @@ class Collections::PublicationsControllerTest < ActionDispatch::IntegrationTest
post collection_publication_path(@collection)
end
assert_redirected_to edit_collection_path(@collection)
assert_turbo_stream action: :replace, target: dom_id(@collection, :publication)
end
test "unpublish a collection" do
@@ -24,6 +24,6 @@ class Collections::PublicationsControllerTest < ActionDispatch::IntegrationTest
delete collection_publication_path(@collection)
end
assert_redirected_to edit_collection_path(@collection)
assert_turbo_stream action: :replace, target: dom_id(@collection, :publication)
end
end
@@ -10,7 +10,7 @@ class Collections::WorkflowsControllerTest < ActionDispatch::IntegrationTest
patch collection_workflow_path(collection), params: { collection: { workflow_id: workflows(:on_call).id } }
assert_redirected_to cards_path(collection_ids: [ collection.id ])
assert_turbo_stream action: :replace, target: dom_id(collection, :workflows)
assert_equal workflows(:on_call), collection.reload.workflow
end
end
@@ -0,0 +1,25 @@
require "test_helper"
class ControllerAuthenticationTest < ActionDispatch::IntegrationTest
test "access without an account slug redirects to launchpad" do
integration_session.default_url_options[:script_name] = "" # no tenant
get cards_path
assert_redirected_to Launchpad.login_url(product: true)
end
test "access with an account slug but no session redirects to launchpad" do
get cards_path
assert_redirected_to Launchpad.login_url(product: true, account: Account.sole)
end
test "access with an account slug and a session allows functional access" do
sign_in_as :kevin
get cards_path
assert_response :success
end
end
+2 -2
View File
@@ -11,7 +11,7 @@ class EventsControllerTest < ActionDispatch::IntegrationTest
test "index" do
get events_path
assert_select "ul.events__time-block[style='grid-area: 17/2']" do
assert_select "div.events__time-block[style='grid-area: 17/2']" do
assert_select "strong", text: "David assigned JZ to Layout is broken"
end
end
@@ -21,7 +21,7 @@ class EventsControllerTest < ActionDispatch::IntegrationTest
get events_path
assert_select "ul.events__time-block[style='grid-area: 22/2']" do
assert_select "div.events__time-block[style='grid-area: 22/2']" do
assert_select "strong", text: "David assigned JZ to Layout is broken"
end
end
@@ -6,14 +6,16 @@ class Sessions::LaunchpadControllerTest < ActionDispatch::IntegrationTest
assert_response :success
assert_equal cookies[:_fizzy_launchpad_sig], "test-sig"
assert_select "form input#sig" do |node|
assert_equal node.length, 1
assert_equal node.first["value"], "test-sig"
end
end
test "create establishes a session when the sig is valid" do
user = users(:david)
cookies[:_fizzy_launchpad_sig] = user.signal_user.perishable_signature
put session_launchpad_path
put session_launchpad_path(params: { sig: user.signal_user.perishable_signature })
assert_redirected_to root_url
assert parsed_cookies.signed[:session_token]
@@ -21,9 +23,8 @@ class Sessions::LaunchpadControllerTest < ActionDispatch::IntegrationTest
test "returns 401 when the sig is invalid" do
user = users(:david)
cookies[:sig] = "not-valid"
put session_launchpad_path
put session_launchpad_path(params: { sig: "invalid" })
assert_response :unauthorized
end
+1 -29
View File
@@ -1,38 +1,10 @@
require "test_helper"
class SessionsControllerTest < ActionDispatch::IntegrationTest
ALLOWED_BROWSER = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15"
DISALLOWED_BROWSER = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/114.0"
test "new" do
get new_session_path
assert_response :success
end
test "new enforces browser compatibility" do
get new_session_path, env: { "HTTP_USER_AGENT" => DISALLOWED_BROWSER }
assert_select "svg", message: /Your browser is not supported/
get new_session_path, env: { "HTTP_USER_AGENT" => ALLOWED_BROWSER }
assert_select "svg", text: /Your browser is not supported/, count: 0
end
test "create with valid credentials" do
post session_path, params: { email_address: "david@37signals.com", password: "secret123456" }
assert_redirected_to root_path
assert cookies[:session_token].present?
end
test "create with invalid credentials" do
post session_path, params: { email_address: "david@37signals.com", password: "wrong" }
assert_redirected_to new_session_path
assert_not cookies[:session_token].present?
end
test "destroy" do
sign_in_as :kevin
delete session_path
assert_redirected_to new_session_path
assert_redirected_to Launchpad.login_url
assert_not cookies[:session_token].present?
end
end
@@ -1,14 +1,14 @@
require "test_helper"
class Signup::AccountsControllerTest < ActionDispatch::IntegrationTest
test "new under a tenanted domain redirects to the root" do
test "new under a tenanted URL redirects to the root" do
get new_signup_account_url
assert_redirected_to root_url
end
test "new under an untenanted domain is OK" do
integration_session.host = "example.com" # no subdomain
test "new under an untenanted URL is OK" do
integration_session.default_url_options[:script_name] = "" # no tenant
get new_signup_account_url, headers: auth_headers
@@ -16,7 +16,7 @@ class Signup::AccountsControllerTest < ActionDispatch::IntegrationTest
end
test "create with invalid params" do
integration_session.host = "example.com" # no subdomain
integration_session.default_url_options[:script_name] = "" # no tenant
post signup_accounts_url,
headers: auth_headers,
@@ -27,7 +27,7 @@ class Signup::AccountsControllerTest < ActionDispatch::IntegrationTest
end
test "create for a new " do
integration_session.host = "example.com" # no subdomain
integration_session.default_url_options[:script_name] = "" # no tenant
assert_difference -> { SignalId::Identity.count }, +1 do
assert_difference -> { SignalId::Account.count }, +1 do
@@ -48,7 +48,7 @@ class Signup::AccountsControllerTest < ActionDispatch::IntegrationTest
end
test "create for an existing identity" do
integration_session.host = "example.com" # no subdomain
integration_session.default_url_options[:script_name] = "" # no tenant
identity = signal_identities(:david)
@@ -72,7 +72,7 @@ class Signup::AccountsControllerTest < ActionDispatch::IntegrationTest
end
test "actions require HTTP basic authentication while we're in internal-only mode" do
integration_session.host = "example.com" # no subdomain
integration_session.default_url_options[:script_name] = "" # no tenant
get new_signup_account_url
+27
View File
@@ -25,6 +25,33 @@ class Card::StatusesTest < ActiveSupport::TestCase
assert_includes Card.published_or_drafted_by(users(:jz)), card
end
test "an event is created when a card is created in the published state" do
Current.session = sessions(:david)
assert_no_difference(-> { Event.count }) do
collections(:writebook).cards.create! creator: users(:kevin), title: "Draft Card"
end
assert_difference(-> { Event.count } => +1) do
@card = collections(:writebook).cards.create! creator: users(:kevin), title: "Published Card", status: :published
end
assert_equal @card, Event.last.eventable
assert_equal "card_published", Event.last.action
end
test "an event is created when a card is published" do
Current.session = sessions(:david)
card = collections(:writebook).cards.create! creator: users(:kevin), title: "Published Card"
assert_difference(-> { Event.count } => +1) do
card.publish
end
assert_equal card, Event.last.eventable
assert_equal "card_published", Event.last.action
end
test "can_recover_abandoned_creation?" do
card = collections(:writebook).cards.create! creator: users(:kevin)
unsaved_card = collections(:writebook).cards.new creator: users(:kevin)
+5 -5
View File
@@ -123,10 +123,10 @@ class Command::Ai::TranslatorTest < ActionDispatch::IntegrationTest
end
test "visit screens" do
assert_command({ commands: [ "/visit #{user_path(@user)}" ] }, "my profile")
assert_command({ commands: [ "/visit #{edit_user_path(@user)}" ] }, "edit my profile")
assert_command({ commands: [ "/visit #{account_settings_path}" ] }, "manage users")
assert_command({ commands: [ "/visit #{account_settings_path}" ] }, "account settings")
assert_command({ commands: [ "/visit #{user_path(@user, script_name: nil)}" ] }, "my profile")
assert_command({ commands: [ "/visit #{edit_user_path(@user, script_name: nil)}" ] }, "edit my profile")
assert_command({ commands: [ "/visit #{account_settings_path(script_name: nil)}" ] }, "manage users")
assert_command({ commands: [ "/visit #{account_settings_path(script_name: nil)}" ] }, "account settings")
end
test "create cards" do
@@ -165,7 +165,7 @@ class Command::Ai::TranslatorTest < ActionDispatch::IntegrationTest
def translate(query, user: @user, context: :list)
raise "Context must be :card or _list" unless context.in?(%i[ card list ])
url = context == :card ? card_url(cards(:logo)) : cards_url
context = Command::Parser::Context.new(user, url: url)
context = Command::Parser::Context.new(user, url: url, script_name: integration_session.default_url_options[:script_name])
translator = Command::Ai::Translator.new(context)
translator.translate(query)
end
+36
View File
@@ -0,0 +1,36 @@
require "test_helper"
class Command::VisitUrlTest < ActionDispatch::IntegrationTest
setup do
@user = users(:david)
@card = cards(:logo)
@script_name = integration_session.default_url_options[:script_name]
end
test "visit a path without the account slug" do
result = visit_command("/foo/1234/bar").execute
assert_kind_of Command::Result::Redirection, result
assert_equal "#{@script_name}/foo/1234/bar", result.url
end
test "visit a path with the account slug" do
result = visit_command("#{@script_name}/foo/1234/bar").execute
assert_kind_of Command::Result::Redirection, result
assert_equal "#{@script_name}/foo/1234/bar", result.url
end
test "visit an object path" do
result = visit_command(@card).execute
assert_kind_of Command::Result::Redirection, result
assert_equal @card, result.url
end
private
def visit_command(url)
context = Command::Parser::Context.new(@user, url: collection_card_url(@card.collection, @card), script_name: integration_session.default_url_options[:script_name])
Command::VisitUrl.new(url: url, context: context)
end
end
+1 -1
View File
@@ -4,7 +4,7 @@ module CommandTestHelper
end
def parse_command(string, context_url: nil, user: users(:david))
context = Command::Parser::Context.new(user, url: context_url)
context = Command::Parser::Context.new(user, url: context_url, script_name: integration_session.default_url_options[:script_name])
parser = Command::Parser.new(context)
parser.parse(string).tap do |command|
command.user = user if command
+1 -1
View File
@@ -6,7 +6,7 @@ module SessionTestHelper
def sign_in_as(user)
cookies[:session_token] = nil
user = users(user) unless user.is_a? User
post session_path, params: { email_address: user.email_address, password: "secret123456" }
put session_launchpad_path, params: { sig: user.signal_user.perishable_signature }
assert cookies[:session_token].present?
end