From 89afb599e48c4da8f9a04762557902a792f14cc1 Mon Sep 17 00:00:00 2001 From: kiragrammel Date: Wed, 16 Aug 2023 18:36:42 +0200 Subject: [PATCH] Apply changes from team members in own editor --- .../channels/synchronized_editor_channel.js | 38 +++++++++++++++++++ app/assets/javascripts/editor/editor.js.erb | 28 ++++++++++++++ app/channels/application_cable/connection.rb | 13 ++++++- app/channels/synchronized_editor_channel.rb | 25 ++++++++++++ app/policies/application_policy.rb | 5 ++- app/policies/programming_group_policy.rb | 4 ++ app/views/exercises/_editor.html.slim | 2 +- .../policies/programming_group_policy_spec.rb | 16 ++++++++ 8 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 app/assets/javascripts/channels/synchronized_editor_channel.js create mode 100644 app/channels/synchronized_editor_channel.rb diff --git a/app/assets/javascripts/channels/synchronized_editor_channel.js b/app/assets/javascripts/channels/synchronized_editor_channel.js new file mode 100644 index 00000000..0aefc567 --- /dev/null +++ b/app/assets/javascripts/channels/synchronized_editor_channel.js @@ -0,0 +1,38 @@ +$(document).on('turbolinks:load', function () { + + if (window.location.pathname.includes('/implement')) { + const editor = $('#editor'); + const exercise_id = editor.data('exercise-id'); + const current_user_id = editor.data('user-id'); + const current_contributor_id = editor.data('contributor-id'); + + if ($.isController('exercises') && current_user_id !== current_contributor_id) { + + App.synchronized_editor = App.cable.subscriptions.create({ + channel: "SynchronizedEditorChannel", exercise_id: exercise_id + }, { + + + connected() { + // Called when the subscription is ready for use on the server + }, + + disconnected() { + // Called when the subscription has been terminated by the server + }, + + received(data) { + // Called when there's incoming data on the websocket for this channel + if (current_user_id !== data['current_user_id']) { + CodeOceanEditor.applyChanges(data['delta']['data']); + } + }, + + send_changes(delta) { + const delta_with_user_id = {current_user_id: current_user_id, delta: delta} + this.perform('send_changes', {delta_with_user_id: delta_with_user_id}); + } + }); + } + } +}); diff --git a/app/assets/javascripts/editor/editor.js.erb b/app/assets/javascripts/editor/editor.js.erb index 1f0aa2ae..852b1443 100644 --- a/app/assets/javascripts/editor/editor.js.erb +++ b/app/assets/javascripts/editor/editor.js.erb @@ -29,6 +29,7 @@ var CodeOceanEditor = { running: false, lastCopyText: null, + lastDeltaObject: null, <% self.class.include Rails.application.routes.url_helpers %> <% @config ||= CodeOcean::Config.new(:code_ocean).read(erb: false) %> @@ -334,6 +335,12 @@ var CodeOceanEditor = { // listener for autosave session.on("change", function (deltaObject, session) { + if (this.compareDeltaObjects(deltaObject, CodeOceanEditor.lastDeltaObject)) { + CodeOceanEditor.lastDeltaObject = null; + return; + } + App.synchronized_editor?.send_changes(deltaObject); + // TODO: This is a workaround for a bug in Ace. Remove when upgrading Ace. this.handleUTF16Surrogates(deltaObject, session); this.resetSaveTimer(); @@ -1015,6 +1022,27 @@ var CodeOceanEditor = { } }, + applyChanges: function (delta) { + this.lastDeltaObject = delta; + this.editors[0].session.doc.applyDeltas([delta]); + }, + + compareDeltaObjects: function (delta, last_delta) { + if (delta === null || last_delta === null) { + return false; + } + + const delta_data = delta.data + // We need this manual comparison because the range uses two different classes + // and there is no function to compare these two objects. + return delta_data.action === last_delta.action && + delta_data.range.start.row === last_delta.range.start.row && + delta_data.range.start.column === last_delta.range.start.column && + delta_data.range.end.row === last_delta.range.end.row && + delta_data.range.end.column === last_delta.range.end.column && + delta_data.text === last_delta.text; + }, + initializeEverything: function () { CodeOceanEditor.editors = []; this.initializeRegexes(); diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb index f2bc6ed9..e2a6d251 100644 --- a/app/channels/application_cable/connection.rb +++ b/app/channels/application_cable/connection.rb @@ -2,10 +2,12 @@ module ApplicationCable class Connection < ActionCable::Connection::Base - identified_by :current_user + identified_by :current_user, :current_contributor def connect + # The order is important here, because a valid user is required to find a valid contributor. self.current_user = find_verified_user + self.current_contributor = find_verified_contributor end def disconnect @@ -24,5 +26,14 @@ module ApplicationCable current_user = ExternalUser.find_by(id: session[:external_user_id]) || InternalUser.find_by(id: session[:user_id]) current_user || reject_unauthorized_connection end + + def find_verified_contributor + # Finding the current_contributor is similar to the code used in application_controller.rb#current_contributor + if session[:pg_id] + current_user.programming_groups.find(session[:pg_id]) + else + current_user + end + end end end diff --git a/app/channels/synchronized_editor_channel.rb b/app/channels/synchronized_editor_channel.rb new file mode 100644 index 00000000..8de89308 --- /dev/null +++ b/app/channels/synchronized_editor_channel.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class SynchronizedEditorChannel < ApplicationCable::Channel + def subscribed + stream_from specific_channel + end + + def unsubscribed + # Any cleanup needed when channel is unsubscribed + stop_all_streams + end + + def specific_channel + reject unless ProgrammingGroupPolicy.new(current_user, programming_group).stream_sync_editor? + "synchronized_editor_channel_group_#{programming_group.id}" + end + + def programming_group + current_contributor if current_contributor.programming_group? + end + + def send_changes(message) + ActionCable.server.broadcast(specific_channel, message['delta_with_user_id']) + end +end diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb index ad0d760b..4f302f7c 100644 --- a/app/policies/application_policy.rb +++ b/app/policies/application_policy.rb @@ -50,14 +50,15 @@ class ApplicationPolicy private :teacher_in_study_group? def author_in_programming_group? + # !! Order is important !! if @record.respond_to? :contributor # e.g. submission possible_programming_group = @record.contributor - elsif @record.respond_to? :context # e.g. file possible_programming_group = @record.context.contributor - elsif @record.respond_to? :submission # e.g. request_for_comment possible_programming_group = @record.submission.contributor + elsif @record.respond_to? :users # e.g. programming_group + possible_programming_group = @record else return false end diff --git a/app/policies/programming_group_policy.rb b/app/policies/programming_group_policy.rb index e0fce84e..6ff95565 100644 --- a/app/policies/programming_group_policy.rb +++ b/app/policies/programming_group_policy.rb @@ -8,4 +8,8 @@ class ProgrammingGroupPolicy < ApplicationPolicy def create? everyone end + + def stream_sync_editor? + admin? || author_in_programming_group? + end end diff --git a/app/views/exercises/_editor.html.slim b/app/views/exercises/_editor.html.slim index a2e3909b..9655ba52 100644 --- a/app/views/exercises/_editor.html.slim +++ b/app/views/exercises/_editor.html.slim @@ -4,7 +4,7 @@ - show_tips_interventions = @show_tips_interventions || "false" - hide_rfc_button = @hide_rfc_button || false -#editor.row data-exercise-id=@exercise.id data-message-depleted=t('exercises.editor.depleted') data-message-timeout=t('exercises.editor.timeout', permitted_execution_time: @exercise.execution_environment.permitted_execution_time) data-message-out-of-memory=t('exercises.editor.out_of_memory', memory_limit: @exercise.execution_environment.memory_limit) data-submissions-url=submissions_path data-user-id=current_user.id data-user-external-id=external_user_external_id data-working-times-url=working_times_exercise_path(@exercise) data-intervention-save-url=intervention_exercise_path(@exercise) data-rfc-interventions=show_rfc_interventions data-break-interventions=show_break_interventions data-tips-interventions=show_tips_interventions +#editor.row data-exercise-id=@exercise.id data-message-depleted=t('exercises.editor.depleted') data-message-timeout=t('exercises.editor.timeout', permitted_execution_time: @exercise.execution_environment.permitted_execution_time) data-message-out-of-memory=t('exercises.editor.out_of_memory', memory_limit: @exercise.execution_environment.memory_limit) data-submissions-url=submissions_path data-user-id=current_user.id data-contributor-id=current_contributor.id data-user-external-id=external_user_external_id data-working-times-url=working_times_exercise_path(@exercise) data-intervention-save-url=intervention_exercise_path(@exercise) data-rfc-interventions=show_rfc_interventions data-break-interventions=show_break_interventions data-tips-interventions=show_tips_interventions - unless @embed_options[:hide_sidebar] - additional_classes = 'sidebar-col' - if @tips.blank? diff --git a/spec/policies/programming_group_policy_spec.rb b/spec/policies/programming_group_policy_spec.rb index 26a6e7f6..9321efd9 100644 --- a/spec/policies/programming_group_policy_spec.rb +++ b/spec/policies/programming_group_policy_spec.rb @@ -15,5 +15,21 @@ describe ProgrammingGroupPolicy do end end end + + permissions(:stream_sync_editor?) do + it 'grants access to admins' do + expect(policy).to permit(create(:admin), programming_group) + end + + it 'grants access to members of the programming group' do + programming_group.users do |user| + expect(policy).to permit(user, programming_group) + end + end + + it 'does not grant access to someone who is not a member of the programming group' do + expect(policy).not_to permit(create(:external_user), programming_group) + end + end end end