Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Add Project Submissions With Hotwire #3852

Merged
merged 1 commit into from
Jun 30, 2023
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
3 changes: 3 additions & 0 deletions app/assets/images/icons/link.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 14 additions & 4 deletions app/builders/tailwind_form_builder.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
class TailwindFormBuilder < ActionView::Helpers::FormBuilder
def text_field(attribute, options = {})
default_opts = { class: classes_for(attribute, options) }
def text_field(attribute, options = {}, &)
if options[:leading_icon]
default_opts = { class: "#{classes_for(attribute, options)} pl-10" }

text_layout(attribute) { super(attribute, options.merge(default_opts)) } + attribute_error_message(attribute)
text_layout(attribute) { leading_icon(&) + super(attribute, options.merge(default_opts)) }
else
default_opts = { class: classes_for(attribute, options) }

text_layout(attribute) { super(attribute, options.merge(default_opts)) }
end + attribute_error_message(attribute)
end

def email_field(attribute, options = {})
Expand Down Expand Up @@ -44,7 +50,7 @@ def classes_for(attribute, options)
end

def text_layout(attribute)
@template.content_tag :div, class: 'mt-1 relative rounded-md shadow-sm' do
@template.content_tag :div, class: 'mt-2 relative rounded-md shadow-sm' do
yield + attribute_error_icon(attribute)
end
end
Expand All @@ -63,6 +69,10 @@ def attribute_error_icon(attribute)
end
end

def leading_icon(&)
@template.content_tag(:div, class: 'pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3', &)
end

def attribute_error_message(attribute)
state = @object.errors[attribute].present? ? :visible : :hidden

Expand Down
1 change: 1 addition & 0 deletions app/components/application_component.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
class ApplicationComponent < ViewComponent::Base
include Classy::Yaml::ComponentHelpers
include Turbo::FramesHelper

private

Expand Down
54 changes: 54 additions & 0 deletions app/components/modal_component.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<%= turbo_frame_tag 'modal' do %>
<div
class="relative z-10"
aria-labelledby="modal-title"
role="dialog"
data-visibility-visible-value="true"
aria-modal="true"
data-controller="modal"
data-action="turbo:submit-end->modal#submitEnd keydown.esc->modal#close">

<div
class="fixed inset-0 bg-gray-500/75 dark:bg-black/70 transition-opacity"
data-modal-target="transitionable"
data-transition-enter="ease-out duration-300"
data-transition-enter-start="opacity-0"
data-transition-enter-end="opacity-100"
data-transition-leave="ease-in duration200"
data-transition-leave-start="opacity-100"
data-transition-leave-end="opacity-0">
</div>

<div class="fixed inset-0 z-10 overflow-y-auto">
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">

<div
class="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6"
data-modal-target="transitionable"
data-transition-enter="ease-out duration-300"
data-transition-enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
data-transition-enter-end="opacity-100 translate-y-0 sm:scale-100"
data-transition-leave="ease-in duration-200"
data-transition-leave-start="opacity-100 translate-y-0 sm:scale-100"
data-transition-leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">

<div class="absolute right-0 top-0 hidden pr-4 pt-4 sm:block">
<button type="button" data-action="click->visibility#off click->modal#close" class="rounded-md bg-white dark:bg-gray-800 text-gray-400 dark:text-gray-300 hover:text-gray-500 dark:hover:text-gray-400 focus:outline-none focus:ring-2 focus:ring-gold-500">
<span class="sr-only">Close</span>
<%= inline_svg_tag 'icons/x.svg', class: 'h-6 w-6', aria: true, title: 'close', desc: 'close button' %>
</button>
</div>

<div class="border-b border-gray-200 dark:border-gray-600 pb-5">
<h3 class="text-lg font-semibold leading-6 text-gray-900 dark:text-gray-300"><%= title %></h3>
</div>

<div class="flex">
<%= content %>
</div>

</div>
</div>
</div>
</div>
<% end %>
9 changes: 9 additions & 0 deletions app/components/modal_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
class ModalComponent < ApplicationComponent
def initialize(title:)
@title = title
end

private

attr_reader :title
end
4 changes: 4 additions & 0 deletions app/components/project_submissions/item_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ def initialize(item:)
@item = item
end

def render?
item.present?
end

private

attr_reader :item
Expand Down
31 changes: 27 additions & 4 deletions app/controllers/lessons/v2_project_submissions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,29 @@ class V2ProjectSubmissionsController < ApplicationController
before_action :set_lesson

def index
@pagy, @project_submissions = pagy(public_project_submissions, items: params.fetch(:limit, 15))
@current_user_submission = current_user.project_submissions.find_by(lesson: @lesson)
@pagy, @project_submissions = pagy(project_submissions_query.public_submissions, items: params.fetch(:limit, 15))
end

private
def new
@project_submission = current_user.project_submissions.new(lesson: @lesson)
end

def public_project_submissions
project_submissions_query.public_submissions
def create
@project_submission = current_user.project_submissions.new(project_submission_params.merge(lesson: @lesson))

respond_to do |format|
if @project_submission.save
format.html { redirect_to lesson_path(@lesson), notice: 'Project submitted' }
format.turbo_stream
else
format.html { render :new, status: :unprocessable_entity }
end
end
end

private

def project_submissions_query
@project_submissions_query ||= ::LessonProjectSubmissionsQuery.new(
lesson: @lesson,
Expand All @@ -23,5 +37,14 @@ def project_submissions_query
def set_lesson
@lesson = Lesson.find(params[:lesson_id])
end

def project_submission_params
params.require(:project_submission).permit(
:repo_url,
:live_preview_url,
:is_public,
:lesson_id
)
end
end
end
34 changes: 34 additions & 0 deletions app/javascript/controllers/modal_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/* eslint-disable class-methods-use-this */
import { Controller } from '@hotwired/stimulus';
import { enter, leave } from 'el-transition';

export default class ModalController extends Controller {
static targets = ['transitionable'];

connect() {
this.lockScroll();
this.transitionableTargets.forEach((element) => enter(element));
}

close() {
Promise.all(this.transitionableTargets.map((element) => leave(element))).then(() => {
this.element.parentElement.removeAttribute('src');
this.element.remove();
this.unlockScroll();
});
}

submitEnd(event) {
if (event.detail.success) {
this.close();
}
}

lockScroll() {
document.body.classList.add('overflow-hidden', 'pr-4');
}

unlockScroll() {
document.body.classList.remove('overflow-hidden', 'pr-4');
}
}
8 changes: 7 additions & 1 deletion app/javascript/stylesheets/buttons.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
.button {
min-width: 100px;
@apply inline-flex items-center px-6 justify-center py-3 border border-transparent
rounded-md shadow-sm text-white focus:outline-none
rounded-md shadow-sm text-white focus:outline-none
focus:ring-2 focus:ring-offset-1 hover:text-white focus:text-white no-underline
cursor-pointer dark:focus:ring-offset-black text-sm
}
Expand All @@ -28,6 +28,12 @@
dark:bg-gray-300 dark:hover:bg-gray-400 focus:text-gray-700 px-4 py-2 dark:hover:text-gray-900
}

.button--white {
@apply bg-white rounded-md px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset
ring-gray-300 hover:bg-gray-50 hover:text-gray-900
dark:bg-white/10 dark:text-white dark:hover:bg-white/20 dark:ring-transparent;
}

.button--clear {
@apply border-gray-300 text-gray-700 hover:text-gray-700 bg-white hover:bg-gray-50
focus:ring-gray-400 focus:text-gray-700 dark:text-gray-300 dark:hover:bg-gray-700
Expand Down
2 changes: 1 addition & 1 deletion app/models/project_submission.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ class ProjectSubmission < ApplicationRecord
validates :live_preview_url, url: true, allow_blank: true
validate :live_preview_allowed
validates :repo_url, presence: { message: 'Required' }
validates :user_id, uniqueness: { scope: :lesson_id }
validates :user_id, uniqueness: { scope: :lesson_id, message: 'You have already submitted a project for this lesson' }

scope :only_public, -> { where(is_public: true) }
scope :not_removed_by_admin, -> { where(discarded_at: nil) }
Expand Down
1 change: 1 addition & 0 deletions app/views/layouts/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
<%= render 'shared/navbar' %>
<%= render 'shared/flash', flash: flash if flash.any? %>

<%= turbo_frame_tag 'modal' %>
<%= yield %>

<%= render 'shared/footer' %>
Expand Down
4 changes: 2 additions & 2 deletions app/views/lessons/_project_submissions.html.erb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<% if Feature.enabled?(:v2_project_submissions, current_user) %>
<%= turbo_frame_tag 'submissions-list', src: lesson_v2_project_submissions_path(lesson, limit: 10) do %>
<div class="flex justify-center h-">
<%= turbo_frame_tag dom_id(@lesson, 'project-submissions'), src: lesson_v2_project_submissions_path(lesson, limit: 10) do %>
<div class="flex justify-center">
<%= inline_svg_tag 'icons/spinner.svg', class: 'animate-spin h-12 w-12 text-gray-400 dark:text-gray-300', aria: true, title: 'dismiss', desc: 'dismiss icon' %>
</div>
<% end %>
Expand Down
52 changes: 52 additions & 0 deletions app/views/lessons/v2_project_submissions/_form.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<%= turbo_frame_tag 'project_submission_form', class: 'w-full' do %>
<%= form_with url: lesson_v2_project_submissions_path(lesson.id), model: project_submission, builder: TailwindFormBuilder do |form| %>
<div class='divide-y divide-gray-200 dark:divide-gray-600 space-y-6'>
<div class="space-y-6 pt-4">
<div>
<%= form.label :repo_url, 'GitHub repository' %>
<%= form.text_field :repo_url, leading_icon: true, autofocus: true, class: 'text-sm', placeholder: 'http://github.com/user-name/repo-name', data: { test_id: 'repo-url-field' } do %>
<%= inline_svg_tag 'socials/github.svg', class: 'h-5 w-5 text-gray-400', aria: true, title: 'Project GitHub repository', desc: 'GitHub icon' %>
<% end %>
</div>

<div>
<%= form.label :live_preview_url, 'Live preview (optional)' %>
<%= form.text_field :live_preview_url, leading_icon: true, placeholder: 'http://example.com', class: 'text-sm', data: { test_id: 'live-preview-url-field' } do %>
<%= inline_svg_tag 'icons/link.svg', class: 'h-5 w-5 text-gray-400', aria: true, title: 'Project live preview', desc: 'Link icon' %>
<% end %>
</div>

<fieldset class="mt-2">
<legend class="text-sm font-medium leading-6 text-gray-900 dark:text-white">Privacy</legend>
<div class="mt-2 space-y-4">
<div class="relative flex items-start">
<div class="absolute flex h-6 items-center">
<%= form.radio_button :is_public, true, class: 'h-4 w-4 border-gray-300 dark:border-gray-500 dark:bg-gray-700/50 dark:checked:bg-gold-600 dark:checked:border-gold-600 dark:focus:ring-offset-gray-800 text-gold-600 focus:ring-gold-600' %>
</div>
<div class="pl-7 text-sm leading-6">
<%= form.label :is_public_true, 'Public to other learners', class: 'dark:text-gray-200' %>
<p class="text-gray-500 dark:text-gray-400">Anyone can see this project in the submissions list.</p>
</div>
</div>
<div>
<div class="relative flex items-start">
<div class="absolute flex h-6 items-center">
<%= form.radio_button :is_public, false, class: 'h-4 w-4 border-gray-300 dark:border-gray-500 dark:bg-gray-700/50 dark:checked:bg-gold-600 dark:checked:border-gold-600 dark:focus:ring-offset-gray-800 text-gold-600 focus:ring-gold-600' %>
</div>
<div class="pl-7 text-sm leading-6">
<%= form.label :is_public_false, 'Private to you' %>
<p class="text-gray-500 dark:text-gray-400">Only you can see this project in the submissions list.</p>
</div>
</div>
</div>
</div>
</fieldset>
</div>

<div class="mt-5 sm:flex sm:flex-row-reverse pt-4">
<%= form.submit 'Save', class: 'cursor-pointer inline-flex w-full justify-center rounded-md button--primary px-5 py-2 text-sm font-semibold text-white shadow-sm sm:ml-3 sm:w-auto', data: { test_id: 'submit-btn' } %>
<button type="button" data-action="click->modal#close" class="button--white mt-3 inline-flex w-full justify-center sm:mt-0 sm:w-auto">Cancel</button>
</div>
</div>
<% end %>
<% end %>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<%= turbo_stream.prepend "submissions-list" do %>
<%= render ProjectSubmissions::ItemComponent.new(item: @project_submission) %>
<% end %>

<%= turbo_stream.remove "add-submission-button" %>
10 changes: 7 additions & 3 deletions app/views/lessons/v2_project_submissions/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<% end %>
</div>

<%= turbo_frame_tag 'submissions-list' do %>
<%= turbo_frame_tag dom_id(@lesson, 'project-submissions') do %>
<div class="mb-8 text-left">

<header class="flex flex-col space-y-6 justify-between items-center pb-8 text-center md:space-y-0 md:text-left md:flex-row">
Expand All @@ -18,11 +18,15 @@
<%= @lesson.course.title %> : (<%= @lesson.title %>)
</h4>
</div>
<% if @current_user_submission.nil? %>
<%= link_to 'Add solution', new_lesson_v2_project_submission_path(@lesson), id: 'add-submission-button', class: 'button button--primary', data: { turbo_frame: 'modal', test_id: 'add_submission_btn' } %>
<% end %>
</header>

<div data-test-id="submissions-list">
<%= turbo_frame_tag 'submissions-list', data: { test_id: 'submissions-list' } do %>
<%= render ProjectSubmissions::ItemComponent.new(item: @current_user_submission) %>
<%= render ProjectSubmissions::ItemComponent.with_collection(@project_submissions) %>
</div>
<% end %>
</div>
<% end %>

Expand Down
3 changes: 3 additions & 0 deletions app/views/lessons/v2_project_submissions/new.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<%= render ModalComponent.new(title: 'Submit your project') do %>
<%= render 'lessons/v2_project_submissions/form', lesson: @lesson, project_submission: @project_submission %>
<% end %>
2 changes: 1 addition & 1 deletion config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@

resources :lessons, only: :show do
resources :project_submissions, only: %i[index], controller: 'lessons/project_submissions'
resources :v2_project_submissions, only: %i[index], controller: 'lessons/v2_project_submissions'
resources :v2_project_submissions, only: %i[index new create], controller: 'lessons/v2_project_submissions'
resource :completion, only: %i[create destroy], controller: 'lessons/completions'
end

Expand Down
4 changes: 2 additions & 2 deletions config/utility_classes.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
text_field:
base: "block w-full border rounded-md py-2 px-3 focus:outline-none dark:bg-gray-700/50 dark:border-gray-500 dark:text-gray-300 dark:placeholder-gray-400 dark:focus:ring-gray-500 dark:focus:ring-2 dark:focus:border-transparent"
valid: "border-gray-300 focus:ring-blue-600 focus:border-blue-600"
base: "block w-full border rounded-md py-2 px-3 focus:outline-none dark:bg-gray-700/50 dark:border-gray-500 dark:text-gray-300 dark:placeholder-gray-400 dark:focus:ring-2 dark:focus:border-transparent"
valid: "border-gray-300 focus:ring-blue-600 focus:border-blue-600 dark:focus:ring-blue-400"
invalid: "pr-10 border-red-300 text-red-900 placeholder-red-300 focus:ring-red-500 focus:border-red-500"
label:
base: "block text-sm font-medium text-gray-700 dark:text-gray-200"
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<div class="mt-1 relative rounded-md shadow-sm"><input class="block w-full border rounded-md py-2 px-3 focus:outline-none dark:bg-gray-700/50 dark:border-gray-500 dark:text-gray-300 dark:placeholder-gray-400 dark:focus:ring-gray-500 dark:focus:ring-2 dark:focus:border-transparent border-gray-300 focus:ring-blue-600 focus:border-blue-600" type="email" value="" name="user[email]" id="user_email" /></div><div class="mt-2 text-sm text-red-600 dark:text-red-500 hidden"></div>
<div class="mt-2 relative rounded-md shadow-sm"><input class="block w-full border rounded-md py-2 px-3 focus:outline-none dark:bg-gray-700/50 dark:border-gray-500 dark:text-gray-300 dark:placeholder-gray-400 dark:focus:ring-2 dark:focus:border-transparent border-gray-300 focus:ring-blue-600 focus:border-blue-600 dark:focus:ring-blue-400" type="email" value="" name="user[email]" id="user_email" /></div><div class="mt-2 text-sm text-red-600 dark:text-red-500 hidden"></div>
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<div class="mt-1 relative rounded-md shadow-sm"><input class="block w-full border rounded-md py-2 px-3 focus:outline-none dark:bg-gray-700/50 dark:border-gray-500 dark:text-gray-300 dark:placeholder-gray-400 dark:focus:ring-gray-500 dark:focus:ring-2 dark:focus:border-transparent border-gray-300 focus:ring-blue-600 focus:border-blue-600" type="password" name="user[password]" id="user_password" /></div><div class="mt-2 text-sm text-red-600 dark:text-red-500 hidden"></div>
<div class="mt-2 relative rounded-md shadow-sm"><input class="block w-full border rounded-md py-2 px-3 focus:outline-none dark:bg-gray-700/50 dark:border-gray-500 dark:text-gray-300 dark:placeholder-gray-400 dark:focus:ring-2 dark:focus:border-transparent border-gray-300 focus:ring-blue-600 focus:border-blue-600 dark:focus:ring-blue-400" type="password" name="user[password]" id="user_password" /></div><div class="mt-2 text-sm text-red-600 dark:text-red-500 hidden"></div>
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
<div class="mt-1 relative rounded-md shadow-sm"><textarea class="mt-1 block w-full border rounded-md py-2 px-3 focus:outline-none dark:bg-gray-700/50 dark:border-gray-500 dark:text-gray-300 dark:placeholder-gray-400 dark:focus:ring-gray-500 dark:focus:ring-2 dark:focus:border-transparent border-gray-300 focus:ring-blue-600 focus:border-blue-600" name="user[username]" id="user_username">
<div class="mt-2 relative rounded-md shadow-sm"><textarea class="mt-1 block w-full border rounded-md py-2 px-3 focus:outline-none dark:bg-gray-700/50 dark:border-gray-500 dark:text-gray-300 dark:placeholder-gray-400 dark:focus:ring-2 dark:focus:border-transparent border-gray-300 focus:ring-blue-600 focus:border-blue-600 dark:focus:ring-blue-400" name="user[username]" id="user_username">
</textarea></div><div class="mt-2 text-sm text-red-600 dark:text-red-500 hidden"></div>
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<div class="mt-1 relative rounded-md shadow-sm"><input class="block w-full border rounded-md py-2 px-3 focus:outline-none dark:bg-gray-700/50 dark:border-gray-500 dark:text-gray-300 dark:placeholder-gray-400 dark:focus:ring-gray-500 dark:focus:ring-2 dark:focus:border-transparent border-gray-300 focus:ring-blue-600 focus:border-blue-600" type="text" name="user[username]" id="user_username" /></div><div class="mt-2 text-sm text-red-600 dark:text-red-500 hidden"></div>
<div class="mt-2 relative rounded-md shadow-sm"><input class="block w-full border rounded-md py-2 px-3 focus:outline-none dark:bg-gray-700/50 dark:border-gray-500 dark:text-gray-300 dark:placeholder-gray-400 dark:focus:ring-2 dark:focus:border-transparent border-gray-300 focus:ring-blue-600 focus:border-blue-600 dark:focus:ring-blue-400" type="text" name="user[username]" id="user_username" /></div><div class="mt-2 text-sm text-red-600 dark:text-red-500 hidden"></div>
Loading