Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions app/concepts/project/operation/update.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# frozen_string_literal: true

class Project
module Operation
class Update
require 'operation_response'

def self.call(params, project)
response = setup_response(project)

setup_deletions(response, params)
update_project_attributes(response, params)
update_component_attributes(response, params)
persist_changes(response)

response
end

class << self
private

def setup_response(project)
response = OperationResponse.new
response[:project] = project
response
end

def setup_deletions(response, params)
existing_component_ids = response[:project].components.pluck(:id)
updated_component_ids = params[:components].pluck(:id)
response[:component_ids_to_delete] = existing_component_ids - updated_component_ids

validate_deletions(response)
end

def validate_deletions(response)
default_component_id = response[:project].components.find_by(default: true)&.id
return unless response[:component_ids_to_delete]&.include?(default_component_id)

response[:error] = I18n.t 'errors.project.editing.delete_default_component'
end

def update_project_attributes(response, params)
return if response[:error]

response[:project].assign_attributes(params.slice(:name))
end

def update_component_attributes(response, params)
return if response[:error]

params[:components].each do |component_params|
if component_params[:id].present?
component = response[:project].components.select { |c| c.id == component_params[:id] }.first
component.assign_attributes(component_params)
else
response[:project].components.build(component_params)
end
end
end

def persist_changes(response)
return if response[:error]

ActiveRecord::Base.transaction do
response[:project].save!
response[:project].components.where(id: response[:component_ids_to_delete]).destroy_all
rescue StandardError
# TODO: log error?
response[:error] = 'Error persisting changes'
end
end
end
end
end
end
25 changes: 13 additions & 12 deletions app/controllers/api/projects_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,33 +3,34 @@
module Api
class ProjectsController < ApiController
require 'phrase_identifier'

before_action :require_oauth_user, only: %i[update]
before_action :load_project
load_and_authorize_resource

def show
@project = Project.find_by!(identifier: params[:id])
render :show, formats: [:json]
end

def update
@project = Project.find_by!(identifier: params[:id])
authorize! :update, @project
result = Project::Operation::Update.call(project_params, @project)

components = project_params[:components]
components.each do |comp_params|
component = Component.find(comp_params[:id])
component.update(comp_params)
if result.success?
render :show, formats: [:json]
else
render json: { error: result[:error] }, status: :bad_request
end
head :ok
end

private

def load_project
@project = Project.find_by!(identifier: params[:id])
end

def project_params
params.require(:project)
.permit(:identifier,
:type,
components: %i[id name extension content])
.permit(:name,
components: %i[id name extension content index])
end
end
end
2 changes: 2 additions & 0 deletions app/models/ability.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ class Ability
include CanCan::Ability

def initialize(user)
can :read, Project

return if user.blank?

can :update, Project, user_id: user
Expand Down
13 changes: 13 additions & 0 deletions app/models/component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,17 @@

class Component < ApplicationRecord
belongs_to :project
validates :name, presence: true
validates :extension, presence: true
validates :index, presence: true, uniqueness: { scope: :project_id }
validate :default_component_protected_properties, on: :update

private

def default_component_protected_properties
return unless default?

errors.add(:name, I18n.t('errors.project.editing.change_default_name')) if name_changed?
errors.add(:extension, I18n.t('errors.project.editing.change_default_extension')) if extension_changed?
end
end
1 change: 1 addition & 0 deletions app/models/project.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ class Project < ApplicationRecord
belongs_to :parent, class_name: 'Project', foreign_key: 'remixed_from_id', optional: true, inverse_of: :children
has_many :components, -> { order(:index) }, dependent: :destroy, inverse_of: :project
has_many :children, class_name: 'Project', foreign_key: 'remixed_from_id', dependent: :nullify, inverse_of: :parent
accepts_nested_attributes_for :components

private

Expand Down
2 changes: 1 addition & 1 deletion app/views/api/projects/show.json.jbuilder
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ json.call(@project, :identifier, :project_type, :name, :user_id)

json.parent(@project.parent, :name, :identifier) if @project.parent

json.components @project.components, :id, :name, :extension, :content
json.components @project.components, :id, :name, :extension, :content, :index
38 changes: 6 additions & 32 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
@@ -1,33 +1,7 @@
# Files in the config/locales directory are used for internationalization
# and are automatically loaded by Rails. If you want to use locales other
# than English, add the necessary files in this directory.
#
# To use the locales, use `I18n.t`:
#
# I18n.t "hello"
#
# In views, this is aliased to just `t`:
#
# <%= t("hello") %>
#
# To use a different locale, set it with `I18n.locale`:
#
# I18n.locale = :es
#
# This would use the information in config/locales/es.yml.
#
# The following keys must be escaped otherwise they will not be retrieved by
# the default I18n backend:
#
# true, false, on, off, yes, no
#
# Instead, surround them with single quotes.
#
# en:
# "true": "foo"
#
# To learn more, please read the Rails Internationalization guide
# available at https://guides.rubyonrails.org/i18n.html.

en:
hello: "Hello world"
errors:
project:
editing:
delete_default_component: 'Cannot delete default file'
change_default_name: 'Cannot amend default file name'
change_default_extension: 'Cannot amend default file extension'
5 changes: 0 additions & 5 deletions config/routes.rb
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
# frozen_string_literal: true

Rails.application.routes.draw do
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html

# Defines the root path route ("/")
# root "articles#index"

namespace :api do
resource :default_project, only: %i[show create] do
get '/html', to: 'default_projects#html'
Expand Down
9 changes: 9 additions & 0 deletions db/migrate/20220307144811_allow_nil_content_on_components.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
class AllowNilContentOnComponents < ActiveRecord::Migration[7.0]
def up
change_column :components, :content, :string, null: true
end

def down
raise ActiveRecord::IrreversibleMigration
end
end
5 changes: 5 additions & 0 deletions db/migrate/20220308115005_add_default_flag_to_components.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class AddDefaultFlagToComponents < ActiveRecord::Migration[7.0]
def change
add_column :components, :default, :boolean, null: false, default: false
end
end
5 changes: 5 additions & 0 deletions db/migrate/20220310120419_add_unique_index_components.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class AddUniqueIndexComponents < ActiveRecord::Migration[7.0]
def change
add_index :components, [:index, :project_id], unique: true
end
end
6 changes: 4 additions & 2 deletions db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@ COMPONENTS:
extension: "py"
location: "main.py"
index: 0
default: true
- name: "emoji"
extension: "py"
location: "emoji.py"
index: 1
default: false
- name: "noemoji"
extension: "py"
location: "noemoji.py"
index: 2
index: 2
default: false
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@ COMPONENTS:
extension: "py"
location: "main.py"
index: 0
default: true
- name: "emoji"
extension: "py"
location: "emoji.py"
index: 1
default: false
- name: "noemoji"
extension: "py"
location: "noemoji.py"
index: 2
index: 2
default: false
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ COMPONENTS:
- name: "main"
extension: "py"
location: "main.py"
index: 0
index: 0
default: true
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ COMPONENTS:
- name: "main"
extension: "py"
location: "main.py"
index: 0
index: 0
default: true
4 changes: 3 additions & 1 deletion lib/tasks/projects.rake
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ namespace :projects do
extension = component['extension']
code = File.read(File.dirname(__FILE__) + "/project_components/#{dir}/#{component['location']}")
index = component['index']
project_component = Component.new(name: name, extension: extension, content: code, index: index)
default = component['default']
project_component = Component.new(name: name, extension: extension, content: code, index: index,
default: default)
new_project.components << project_component
end
new_project.save
Expand Down
71 changes: 71 additions & 0 deletions spec/concepts/project/operation/update_default_component_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe Project::Operation::Update, type: :unit do
subject(:update) { described_class.call(project_params, project) }

let!(:project) { create(:project, :with_default_component) }
let(:default_component) { project.components.first }

describe '.call' do
context 'when default file is removed' do
let(:project_params) do
{
name: 'updated project name',
components: []
}
end

it 'returns failure? true' do
expect(update.failure?).to eq(true)
end

it 'returns error message' do
expect(update[:error]).to eq(I18n.t('errors.project.editing.delete_default_component'))
end

it 'does not delete the default component' do
expect { update }.not_to change(Component, :count)
end

it 'does not update project' do
expect { update }.not_to change { project.reload.name }
end
end

context 'when default file properties are changed' do
let(:default_component_params) do
default_component.attributes.symbolize_keys.slice(
:id,
:name,
:content,
:extension,
:index
)
end

let(:project_params) do
{
name: 'updated project name',
components: [default_component_params]
}
end

it 'does not update file name' do
default_component_params[:name] = 'Updated name'
expect { update }.not_to change { default_component.reload.name }
end

it 'does not update file extension' do
default_component_params[:extension] = 'txt'
expect { update }.not_to change { default_component.reload.extension }
end

it 'does not update project' do
default_component_params[:name] = 'Updated name'
expect { update }.not_to change { project.reload.name }
end
end
end
end
Loading