Skip to content

Commit

Permalink
Feature: Add Project Submissions With Hotwire (#3852)
Browse files Browse the repository at this point in the history
Because:
* We are converting project submissions from react to Hotwire.

This commit:
* Add Hotwire modal
* Display project submissions form partial when within the modal when
adding a project
* Redesign the project submission form, modern styling and clearer
privacy controls.
* Prepend users and hide the add submission button with a turbo stream


https://github.com/TheOdinProject/theodinproject/assets/7963776/592c6dcd-a426-4e24-b810-970715df4254
  • Loading branch information
KevinMulhern authored Jun 30, 2023
1 parent 3a25449 commit 552de31
Show file tree
Hide file tree
Showing 28 changed files with 314 additions and 24 deletions.
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

0 comments on commit 552de31

Please sign in to comment.