Skip to content
45 changes: 45 additions & 0 deletions app/graphql/mutations/remix_project.rb
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions app/graphql/types/mutation_type.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
13 changes: 13 additions & 0 deletions app/graphql/types/remix_project_input_type.rb
Original file line number Diff line number Diff line change
@@ -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
50 changes: 50 additions & 0 deletions db/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""
Expand Down Expand Up @@ -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
"""
Expand Down
10 changes: 5 additions & 5 deletions lib/concepts/project/operations/create_remix.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
11 changes: 8 additions & 3 deletions spec/concepts/project/create_remix_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand Down Expand Up @@ -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)
Expand All @@ -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

Expand Down
120 changes: 120 additions & 0 deletions spec/graphql/mutations/remix_project_mutation_spec.rb
Original file line number Diff line number Diff line change
@@ -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