Live collaboration with Yjs over ActionCable
All editors now work on a server document at the same time instead of taking turns behind an edit lock. - The document's content is a sequence of Yjs updates (document_updates); DocumentChannel stores and relays them immediately, checks access on subscribe and again whenever links or memberships change. - app/frontend/collab/: the store stays position-based; a binding maps it to a Yjs form with stable row/column ids, so a row inserted by one person doesn't misplace what another is typing. Remote changes are applied only after pending local ones went into Yjs. - Per-person undo (Y.UndoManager), presence via awareness: the others' selections as coloured boxes with names, avatars in the toolbar. - Existing JSON documents convert on first open, deterministically, so two people opening one at once end up with the same Yjs state. - Editors compact the stored updates after 200 and keep a JSON snapshot current for export and duplicating. Edit lock and polling are gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,16 @@
|
||||
require "test_helper"
|
||||
|
||||
class DocumentsApiTest < ActionDispatch::IntegrationTest
|
||||
include ActionCable::TestHelper
|
||||
|
||||
setup do
|
||||
@anna = create_user("anna@example.com")
|
||||
@bob = create_user("bob@example.com")
|
||||
@doc = create_document(@anna)
|
||||
end
|
||||
|
||||
def put_content(doc, base_version, headers: {})
|
||||
patch "/api/documents/#{doc.id}", params: { content: DOC_JSON, base_version: }, headers:, as: :json
|
||||
def rename(doc, title, headers: {})
|
||||
patch "/api/documents/#{doc.id}", params: { title: }, headers:, as: :json
|
||||
end
|
||||
|
||||
test "listing and creating need an account" do
|
||||
@@ -38,39 +40,6 @@ class DocumentsApiTest < ActionDispatch::IntegrationTest
|
||||
assert_response :not_found
|
||||
end
|
||||
|
||||
test "save with version check; stale base_version is a conflict" do
|
||||
sign_in @anna
|
||||
put_content(@doc, 1)
|
||||
assert_response :success
|
||||
assert_equal 2, json.dig("document", "version")
|
||||
|
||||
put_content(@doc, 1)
|
||||
assert_response :conflict
|
||||
assert_equal 2, json["version"]
|
||||
end
|
||||
|
||||
test "known_version returns metadata only when unchanged" do
|
||||
sign_in @anna
|
||||
get "/api/documents/#{@doc.id}", params: { known_version: 1 }
|
||||
assert json["unchanged"]
|
||||
assert_nil json.dig("document", "content")
|
||||
end
|
||||
|
||||
test "edit lock blocks saves from another tab" do
|
||||
sign_in @anna
|
||||
post "/api/documents/#{@doc.id}/lock", headers: { "X-Client-Id" => "tab-a" }
|
||||
assert json.dig("lock", "mine")
|
||||
|
||||
post "/api/documents/#{@doc.id}/lock", headers: { "X-Client-Id" => "tab-b" }
|
||||
assert_not json.dig("lock", "mine")
|
||||
put_content(@doc, 1, headers: { "X-Client-Id" => "tab-b" })
|
||||
assert_response :locked
|
||||
|
||||
delete "/api/documents/#{@doc.id}/lock", headers: { "X-Client-Id" => "tab-a" }
|
||||
put_content(@doc, 1, headers: { "X-Client-Id" => "tab-b" })
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "share link without account: read with token; viewer cannot write" do
|
||||
link = @doc.share_links.create!(role: :viewer, created_by: @anna)
|
||||
headers = { "X-Share-Token" => link.token }
|
||||
@@ -79,7 +48,7 @@ class DocumentsApiTest < ActionDispatch::IntegrationTest
|
||||
assert_equal "viewer", json.dig("document", "role")
|
||||
get "/api/documents/#{@doc.id}", headers: headers
|
||||
assert_response :success
|
||||
put_content(@doc, 1, headers:)
|
||||
rename(@doc, "Neu", headers:)
|
||||
assert_response :forbidden
|
||||
|
||||
get "/api/documents/#{@doc.id}", headers: { "X-Share-Token" => "wrong" }
|
||||
@@ -92,10 +61,18 @@ class DocumentsApiTest < ActionDispatch::IntegrationTest
|
||||
post "/api/documents/#{@doc.id}/join", params: { token: link.token }, as: :json
|
||||
get "/api/documents"
|
||||
assert_equal [ [ @doc.id, "editor" ] ], json["documents"].map { [ _1["id"], _1["role"] ] }
|
||||
put_content(@doc, 1)
|
||||
rename(@doc, "Von Bob")
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "renaming tells everyone connected" do
|
||||
sign_in @anna
|
||||
assert_broadcast_on(DocumentChannel.broadcasting_for(@doc), { type: "meta", title: "Neu" }) do
|
||||
rename(@doc, "Neu")
|
||||
end
|
||||
assert_equal "Neu", @doc.reload.title
|
||||
end
|
||||
|
||||
test "owner deletes; a member only leaves" do
|
||||
DocumentMembership.grant!(document: @doc, user: @bob, role: :editor, source: :invite)
|
||||
sign_in @bob
|
||||
|
||||
Reference in New Issue
Block a user