diff --git a/app/graphql/mutations/create_component.rb b/app/graphql/mutations/create_component.rb new file mode 100644 index 000000000..421733d30 --- /dev/null +++ b/app/graphql/mutations/create_component.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Mutations + class CreateComponent < BaseMutation + description 'A mutation to create a new component' + input_object_class Types::CreateComponentInputType + + field :component, Types::ComponentType, description: 'The component that has been created' + + def resolve(**input) + project = GlobalID.find(input[:project_id]) + + raise GraphQL::ExecutionError, 'Project not found' unless project + + component = Component.new input + component.project = project + + unless context[:current_ability].can?(:create, component) + raise GraphQL::ExecutionError, + 'You are not permitted to update this component' + end + + return { component: } if component.save + + raise GraphQL::ExecutionError, component.errors.full_messages.join(', ') + end + + def ready?(**_args) + if context[:current_ability]&.can?(:create, Component, Project.new(user_id: context[:current_user_id])) + return true + end + + raise GraphQL::ExecutionError, 'You are not permitted to create a component' + end + end +end diff --git a/app/graphql/types/create_component_input_type.rb b/app/graphql/types/create_component_input_type.rb new file mode 100644 index 000000000..ded6f31e9 --- /dev/null +++ b/app/graphql/types/create_component_input_type.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + class CreateComponentInputType < Types::BaseInputObject + description 'Represents a component during creation' + + argument :content, String, required: false, description: 'The text content of the component' + argument :default, Boolean, required: true, description: 'If this is the default component on a project' + argument :extension, String, required: true, description: 'The file extension of the component, e.g. html, csv, py' + argument :name, String, required: true, description: 'The name of the file' + argument :project_id, String, required: true, description: 'The easy-to-rememeber identifier of the project' + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 0bae85f7e..e91e834a2 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -2,9 +2,12 @@ module Types class MutationType < Types::BaseObject + # rubocop:disable GraphQL/ExtractType + field :create_component, mutation: Mutations::CreateComponent, description: 'Create a component' 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' + # rubocop:enable GraphQL/ExtractType end end diff --git a/db/schema.graphql b/db/schema.graphql index 3537326da..de2a89421 100644 --- a/db/schema.graphql +++ b/db/schema.graphql @@ -83,6 +83,56 @@ type ComponentEdge { node: Component } +""" +Represents a component during creation +""" +input CreateComponentInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The text content of the component + """ + content: String + + """ + If this is the default component on a project + """ + default: Boolean! + + """ + The file extension of the component, e.g. html, csv, py + """ + extension: String! + + """ + The name of the file + """ + name: String! + + """ + The easy-to-rememeber identifier of the project + """ + projectId: String! +} + +""" +Autogenerated return type of CreateComponent. +""" +type CreateComponentPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The component that has been created + """ + component: Component +} + """ Represents a project during creation """ @@ -219,6 +269,16 @@ type ImageEdge { } type Mutation { + """ + Create a component + """ + createComponent( + """ + Parameters for CreateComponent + """ + input: CreateComponentInput! + ): CreateComponentPayload + """ Create a project, complete with components """ diff --git a/spec/graphql/mutations/create_component_mutation_spec.rb b/spec/graphql/mutations/create_component_mutation_spec.rb new file mode 100644 index 000000000..65e39ee18 --- /dev/null +++ b/spec/graphql/mutations/create_component_mutation_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'mutation CreateComponent() { ... }' do + subject(:result) { execute_query(query: mutation, variables:) } + + let(:project) { create(:project) } + let(:project_id) { project.to_gid_param } + + let(:mutation) { 'mutation CreateComponent($component: CreateComponentInput!) { createComponent(input: $component) { component { id } } }' } + # let(:project_id) { 'dummy-id' } + let(:variables) do + { + component: { + projectId: project_id, + name: 'test', + extension: 'py', + content: 'blah', + default: false + } + } + end + + it { expect(mutation).to be_a_valid_graphql_query } + + context 'when unauthenticated' do + it 'does not create a component' do + expect { result }.not_to change(Component, :count) + end + + it 'returns an error' do + expect(result.dig('errors', 0, 'message')).not_to be_blank + end + end + + context 'when the graphql context is unset' do + let(:graphql_context) { nil } + + it 'does not create a component' do + expect { result }.not_to change(Component, :count) + end + end + + context 'when authenticated' do + let(:current_user_id) { SecureRandom.uuid } + let(:project) { create(:project, user_id: current_user_id) } + + it 'returns the component ID' do + expect(result.dig('data', 'createComponent', 'component', 'id')).not_to be_nil + end + + context 'when project id doesnt exist' do + let(:project_id) { 'dummy-id' } + + it 'returns an error' do + expect(result.dig('errors', 0, 'message')).not_to be_blank + end + end + + context 'when project id exists but belongs to someone else' do + let(:project) { create(:project) } + + it 'returns an error' do + expect(result.dig('errors', 0, 'message')).not_to be_blank + end + end + + context 'when project component fails to save' do + before do + component = Component.new + allow(component).to receive(:save).and_return(false) + allow(Component).to receive(:new).and_return(component) + end + + it 'returns an error' do + expect(result.dig('errors', 0, 'message')).not_to be_nil + end + end + end +end