diff --git a/app/graphql/mutations/remix_project.rb b/app/graphql/mutations/remix_project.rb new file mode 100644 index 000000000..9ff9db36f --- /dev/null +++ b/app/graphql/mutations/remix_project.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Mutations + class RemixProject < BaseMutation + description 'A mutation to remix an existing project' + input_object_class Types::RemixProjectInputType + + field :project, Types::ProjectType, description: 'The project that has been created' + + def resolve(**input) + original_project = GlobalID.find(input[:id]) + raise GraphQL::ExecutionError, 'Project not found' unless original_project + raise GraphQL::ExecutionError, 'You are not permitted to read this project' unless can_read?(original_project) + + params = { + name: input[:name] || original_project.name, + identifier: original_project.identifier, + components: remix_components(input, original_project) + } + response = Project::CreateRemix.call(params:, user_id: context[:current_user_id], + original_project:) + raise GraphQL::ExecutionError, response[:error] unless response.success? + + { project: response[:project] } + end + + def ready?(**_args) + return true if can_create_project? + + raise GraphQL::ExecutionError, 'You are not permitted to create a project' + end + + def can_create_project? + context[:current_ability]&.can?(:create, Project, user_id: context[:current_user_id]) + end + + def can_read?(original_project) + context[:current_ability]&.can?(:show, original_project) + end + + def remix_components(input, original_project) + input[:components]&.map(&:to_h) || original_project.components + end + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 547693db6..0bae85f7e 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -4,6 +4,7 @@ module Types class MutationType < Types::BaseObject field :create_project, mutation: Mutations::CreateProject, description: 'Create a project, complete with components' field :delete_project, mutation: Mutations::DeleteProject, description: 'Delete an existing project' + field :remix_project, mutation: Mutations::RemixProject, description: 'Remix a project' field :update_project, mutation: Mutations::UpdateProject, description: 'Update fields on an existing project' end end diff --git a/app/graphql/types/remix_project_input_type.rb b/app/graphql/types/remix_project_input_type.rb new file mode 100644 index 000000000..ab9ddda3c --- /dev/null +++ b/app/graphql/types/remix_project_input_type.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + class RemixProjectInputType < Types::BaseInputObject + description 'Represents a project during a remix' + + argument :components, [Types::ProjectComponentInputType], + required: false, + description: 'The components of the remixed project' + argument :id, String, required: true, description: 'The ID of the project to remix' + argument :name, String, required: false, description: 'The name of the remixed project' + end +end diff --git a/db/schema.graphql b/db/schema.graphql index 84719db7a..3537326da 100644 --- a/db/schema.graphql +++ b/db/schema.graphql @@ -239,6 +239,16 @@ type Mutation { input: DeleteProjectInput! ): DeleteProjectPayload + """ + Remix a project + """ + remixProject( + """ + Parameters for RemixProject + """ + input: RemixProjectInput! + ): RemixProjectPayload + """ Update fields on an existing project """ @@ -512,6 +522,46 @@ type Query { ): ProjectConnection } +""" +Represents a project during a remix +""" +input RemixProjectInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The components of the remixed project + """ + components: [ProjectComponentInput!] + + """ + The ID of the project to remix + """ + id: String! + + """ + The name of the remixed project + """ + name: String +} + +""" +Autogenerated return type of RemixProject. +""" +type RemixProjectPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The project that has been created + """ + project: Project +} + """ Represents a project during an update """ diff --git a/lib/concepts/project/operations/create_remix.rb b/lib/concepts/project/operations/create_remix.rb index 78a103dd0..10a3dda5b 100644 --- a/lib/concepts/project/operations/create_remix.rb +++ b/lib/concepts/project/operations/create_remix.rb @@ -26,13 +26,12 @@ def validate_params(response, params, user_id, original_project) def remix_project(response, params, user_id, original_project) response[:project] = create_remix(original_project, params, user_id) - response[:project].save! response end def create_remix(original_project, params, user_id) - remix = format_project(original_project, user_id) + remix = format_project(original_project, params, user_id) original_project.images.each do |image| remix.images.attach(image.blob) @@ -45,12 +44,13 @@ def create_remix(original_project, params, user_id) remix end - def format_project(original_project, user_id) + def format_project(original_project, params, user_id) original_project.dup.tap do |proj| - proj.user_id = user_id - proj.remixed_from_id = original_project.id proj.identifier = PhraseIdentifier.generate proj.locale = nil + proj.name = params[:name] + proj.user_id = user_id + proj.remixed_from_id = original_project.id end end end diff --git a/spec/concepts/project/create_remix_spec.rb b/spec/concepts/project/create_remix_spec.rb index 058fe4816..21cd21d65 100644 --- a/spec/concepts/project/create_remix_spec.rb +++ b/spec/concepts/project/create_remix_spec.rb @@ -10,7 +10,7 @@ let(:remix_params) do component = original_project.components.first { - name: original_project.name, + name: 'My remixed project', identifier: original_project.identifier, components: [ { @@ -50,6 +50,11 @@ expect(remixed_project.locale).to be_nil end + it 'renames the project' do + remixed_project = create_remix[:project] + expect(remixed_project.name).to eq('My remixed project') + end + it 'assigns user_id to new project' do remixed_project = create_remix[:project] expect(remixed_project.user_id).to eq(user_id) @@ -58,8 +63,8 @@ it 'duplicates properties on new project' do remixed_project = create_remix[:project] - remixed_attrs = remixed_project.attributes.symbolize_keys.slice(:name, :project_type) - original_attrs = original_project.attributes.symbolize_keys.slice(:name, :project_type) + remixed_attrs = remixed_project.attributes.symbolize_keys.slice(:project_type) + original_attrs = original_project.attributes.symbolize_keys.slice(:project_type) expect(remixed_attrs).to eq(original_attrs) end diff --git a/spec/graphql/mutations/remix_project_mutation_spec.rb b/spec/graphql/mutations/remix_project_mutation_spec.rb new file mode 100644 index 000000000..428879338 --- /dev/null +++ b/spec/graphql/mutations/remix_project_mutation_spec.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'mutation RemixProject() { ... }' do + subject(:result) { execute_query(query: mutation, variables:) } + + let(:mutation) do + 'mutation RemixProject($id: String!, $name: String, $components: [ProjectComponentInput!]) { + remixProject(input: { id: $id, name: $name, components: $components }) { + project { + id + } + } + } + ' + end + let(:project) { create(:project, :with_default_component, user_id: SecureRandom.uuid) } + let(:project_id) { project.to_gid_param } + let(:variables) { { id: project_id } } + + before do + project + end + + it { expect(mutation).to be_a_valid_graphql_query } + + context 'when unauthenticated' do + let(:current_user_id) { nil } + + it 'does not create a project' do + expect { result }.not_to change(Project, :count) + end + + it 'returns "not permitted to create" error' do + expect(result.dig('errors', 0, 'message')).to match(/not permitted to create/) + end + end + + context 'when original project not found' do + let(:project_id) { SecureRandom.uuid } + let(:current_user_id) { SecureRandom.uuid } + + it 'returns "not found" error' do + expect(result.dig('errors', 0, 'message')).to match(/not found/) + end + + it 'does not create a project' do + expect { result }.not_to change(Project, :count) + end + end + + context 'when user cannot view original project' do + let(:current_user_id) { SecureRandom.uuid } + + it 'returns "not permitted to read" error' do + expect(result.dig('errors', 0, 'message')).to match(/not permitted to read/) + end + + it 'does not create a project' do + expect { result }.not_to change(Project, :count) + end + end + + context 'when authenticated and project exists' do + let(:current_user_id) { project.user_id } + let(:returned_gid) { result.dig('data', 'remixProject', 'project', 'id') } + let(:remixed_project) { GlobalID.find(returned_gid) } + + it 'creates a project' do + expect { result }.to change(Project, :count).by(1) + end + + it 'returns graphql id for remixed project' do + expect(returned_gid).to eq Project.order(created_at: :asc).last.to_gid_param + end + + context 'when name and components not specified' do + it 'uses original project name' do + expect(remixed_project.name).to eq(project.name) + end + + it 'uses original project components' do + expect(remixed_project.components[0].content).to eq(project.components[0].content) + end + end + + context 'when name and components specified' do + before do + variables[:name] = 'My amazing remix' + variables[:components] = [ + { + name: 'main', + extension: 'py', + default: true, + content: "print('this is amazing')" + } + ] + end + + it 'updates remixed project name if given' do + expect(remixed_project.name).to eq(variables[:name]) + end + + it 'updates remixed project components if given' do + expect(remixed_project.components[0].content).to eq(variables[:components][0][:content]) + end + end + + context 'when project creation fails' do + before do + allow(Project::CreateRemix).to receive(:call).and_return(OperationResponse[error: 'Something went wrong']) + end + + it 'returns an error' do + expect(result.dig('errors', 0, 'message')).to eq 'Something went wrong' + end + end + end +end