diff --git a/app/controllers/api/lessons_controller.rb b/app/controllers/api/lessons_controller.rb index 0f21d1260..ebcf702db 100644 --- a/app/controllers/api/lessons_controller.rb +++ b/app/controllers/api/lessons_controller.rb @@ -114,7 +114,8 @@ def base_params :name, :project_type, :locale, - { components: %i[id name extension content index default] } + { components: %i[id name extension content index default] }, + { scratch_component: {} } ] } ) diff --git a/app/controllers/api/projects_controller.rb b/app/controllers/api/projects_controller.rb index d3937eca1..edba3986a 100644 --- a/app/controllers/api/projects_controller.rb +++ b/app/controllers/api/projects_controller.rb @@ -117,6 +117,7 @@ def base_params { components: %i[id name extension content index default] }, + scratch_component: {}, parent: {}, image_list: [] ) diff --git a/app/controllers/api/scratch/projects_controller.rb b/app/controllers/api/scratch/projects_controller.rb index 4904724d3..2e044493c 100644 --- a/app/controllers/api/scratch/projects_controller.rb +++ b/app/controllers/api/scratch/projects_controller.rb @@ -5,12 +5,16 @@ module Scratch class ProjectsController < ScratchController skip_before_action :authorize_user, only: [:show] skip_before_action :check_scratch_feature, only: [:show] + before_action :load_project, only: %i[show update] def show - render :show, formats: [:json] + render json: @project.scratch_component.content end def update + scratch_content = params.permit!.slice(:meta, :targets, :monitors, :extensions) + @project.scratch_component&.content = scratch_content.to_unsafe_h + @project.save! render json: { status: 'ok' }, status: :ok end end diff --git a/app/controllers/api/scratch/scratch_controller.rb b/app/controllers/api/scratch/scratch_controller.rb index 2500b8495..c1b792141 100644 --- a/app/controllers/api/scratch/scratch_controller.rb +++ b/app/controllers/api/scratch/scratch_controller.rb @@ -16,6 +16,11 @@ def check_scratch_feature raise ActiveRecord::RecordNotFound, 'Not Found' end + + def load_project + project_loader = ProjectLoader.new(params[:id], [params[:locale]]) + @project = project_loader.load + end end end end diff --git a/app/graphql/mutations/create_project.rb b/app/graphql/mutations/create_project.rb index 07c98c3f7..6ee9247ce 100644 --- a/app/graphql/mutations/create_project.rb +++ b/app/graphql/mutations/create_project.rb @@ -10,7 +10,8 @@ class CreateProject < BaseMutation def resolve(**input) project_hash = input.merge( user_id: context[:current_user]&.id, - components: input[:components]&.map(&:to_h) + components: input[:components]&.map(&:to_h), + scratch_component: input[:scratch_component]&.to_h ) response = Project::Create.call(project_hash:, current_user: context[:current_user]) diff --git a/app/models/project.rb b/app/models/project.rb index 51334d6d7..1cfa3faab 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -13,6 +13,7 @@ module Types belongs_to :parent, optional: true, class_name: :Project, foreign_key: :remixed_from_id, inverse_of: :remixes has_many :remixes, dependent: :nullify, class_name: :Project, foreign_key: :remixed_from_id, inverse_of: :parent has_many :components, -> { order(default: :desc, name: :asc) }, dependent: :destroy, inverse_of: :project + has_one :scratch_component, dependent: :destroy, inverse_of: :project, required: false has_many :project_errors, dependent: :nullify has_many_attached :images has_many_attached :videos @@ -20,6 +21,7 @@ module Types has_one :school_project, dependent: :destroy accepts_nested_attributes_for :components + accepts_nested_attributes_for :scratch_component before_validation :check_unique_not_null, on: :create before_validation :create_school_project_if_needed @@ -67,6 +69,10 @@ def components=(array) super(array.map { |o| o.is_a?(Hash) ? Component.new(o) : o }) end + def scratch_component=(value) + super(value.is_a?(Hash) ? ScratchComponent.new(value) : value) + end + def last_edited_at # datetime that the project or one of its components was last updated [updated_at, components.maximum(:updated_at)].compact.max diff --git a/app/models/scratch_component.rb b/app/models/scratch_component.rb new file mode 100644 index 000000000..6bc8c8bd2 --- /dev/null +++ b/app/models/scratch_component.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ScratchComponent < ApplicationRecord + belongs_to :project +end diff --git a/db/migrate/20260309104851_create_scratch_components.rb b/db/migrate/20260309104851_create_scratch_components.rb new file mode 100644 index 000000000..594e7e345 --- /dev/null +++ b/db/migrate/20260309104851_create_scratch_components.rb @@ -0,0 +1,10 @@ +class CreateScratchComponents < ActiveRecord::Migration[7.2] + def change + create_table :scratch_components, id: :uuid do |t| + t.jsonb :content + t.references :project, null: false, foreign_key: true, type: :uuid, index: { unique: true } + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 356389a32..afa3525a9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_01_26_130135) do +ActiveRecord::Schema[7.2].define(version: 2026_03_09_104851) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -331,6 +331,14 @@ t.index ["school_roll_number"], name: "index_schools_on_school_roll_number", unique: true, where: "(rejected_at IS NULL)" end + create_table "scratch_components", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.jsonb "content" + t.uuid "project_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["project_id"], name: "index_scratch_components_on_project_id", unique: true + end + create_table "teacher_invitations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "email_address" t.uuid "school_id", null: false @@ -380,6 +388,7 @@ add_foreign_key "school_project_transitions", "school_projects" add_foreign_key "school_projects", "projects" add_foreign_key "school_projects", "schools" + add_foreign_key "scratch_components", "projects" add_foreign_key "teacher_invitations", "schools" add_foreign_key "user_jobs", "good_jobs" end diff --git a/lib/concepts/project/operations/create.rb b/lib/concepts/project/operations/create.rb index a2e942889..6c4c9e5cc 100644 --- a/lib/concepts/project/operations/create.rb +++ b/lib/concepts/project/operations/create.rb @@ -18,8 +18,9 @@ def call(project_hash:, current_user:) def build_project(project_hash, current_user) project_hash[:identifier] = PhraseIdentifier.generate unless current_user&.experience_cs_admin? - new_project = Project.new(project_hash.except(:components)) + new_project = Project.new(project_hash.except(:components, :scratch_component)) new_project.components.build(project_hash[:components]) + new_project.build_scratch_component(project_hash[:scratch_component]) if project_hash[:scratch_component].present? new_project end end diff --git a/spec/factories/scratch_components.rb b/spec/factories/scratch_components.rb new file mode 100644 index 000000000..22c211e40 --- /dev/null +++ b/spec/factories/scratch_components.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :scratch_component do + content { { targets: [], monitors: [], extensions: [], meta: {} } } + project + end +end diff --git a/spec/features/scratch/showing_a_scratch_project_spec.rb b/spec/features/scratch/showing_a_scratch_project_spec.rb index 12e584740..40bd25c3b 100644 --- a/spec/features/scratch/showing_a_scratch_project_spec.rb +++ b/spec/features/scratch/showing_a_scratch_project_spec.rb @@ -4,7 +4,14 @@ RSpec.describe 'Showing a Scratch project', type: :request do it 'returns scratch project JSON' do - get '/api/scratch/projects/any-identifier' + project = create( + :project, + project_type: Project::Types::CODE_EDITOR_SCRATCH, + locale: 'en' + ) + create(:scratch_component, project: project) + + get "/api/scratch/projects/#{project.identifier}" expect(response).to have_http_status(:ok) diff --git a/spec/features/scratch/updating_a_scratch_project_spec.rb b/spec/features/scratch/updating_a_scratch_project_spec.rb index eaa91936b..2ada2ce06 100644 --- a/spec/features/scratch/updating_a_scratch_project_spec.rb +++ b/spec/features/scratch/updating_a_scratch_project_spec.rb @@ -21,20 +21,31 @@ it 'responds 404 Not Found when cat_mode is not enabled' do authenticated_in_hydra_as(teacher) - put '/api/scratch/projects/any-identifier', params: { project: { targets: [] } }, headers: cookie_headers + put '/api/scratch/projects/any-identifier', params: { content: { targets: [] } }, headers: cookie_headers expect(response).to have_http_status(:not_found) end it 'updates a project when cat_mode is enabled and a cookie is provided' do + # Arrange authenticated_in_hydra_as(teacher) Flipper.enable_actor :cat_mode, school + project = create( + :project, + project_type: Project::Types::CODE_EDITOR_SCRATCH, + locale: 'en' + ) + create(:scratch_component, project: project) - put '/api/scratch/projects/any-identifier', params: { project: { targets: [] } }, headers: cookie_headers + # Act + put "/api/scratch/projects/#{project.identifier}", params: { targets: ['some update'] }, headers: cookie_headers + # Assert expect(response).to have_http_status(:ok) data = JSON.parse(response.body, symbolize_names: true) expect(data[:status]).to eq('ok') + + expect(project.reload.scratch_component.content.to_h['targets']).to eq(['some update']) end end