Skip to content

Commit

Permalink
Feature: Convert lesson preview to Hotwire
Browse files Browse the repository at this point in the history
Because:
* We can simplify the preview feature code with Hotwire
* New and improved share preview UX

This commit:
* Moves input and html preview tabs to rails views.
* Adds reusable tabs and autosubmit and clipboard controllers.
* Adds a share modal that is handled by a new preview share controller to keep persisting separate from displaying.
* Amends our modal to display center of the screen on mobile.
* Increases the share preview rate limit to 5 per minute - it may be clicked more now that its easier to find above the preview.
  • Loading branch information
KevinMulhern committed Sep 1, 2023
1 parent 456f6fc commit 7c1cc00
Show file tree
Hide file tree
Showing 20 changed files with 246 additions and 39 deletions.
3 changes: 3 additions & 0 deletions app/assets/images/icons/clipboard.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions app/assets/images/icons/share.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 3 additions & 3 deletions app/components/modal_component.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@
</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="flex min-h-full items-center justify-center p-4 text-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"
class="relative w-full 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: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"
Expand All @@ -32,7 +32,7 @@
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">
<div class="absolute right-0 top-0 pr-4 pt-4">
<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' %>
Expand Down
19 changes: 19 additions & 0 deletions app/controllers/lessons/previews/share_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
module Lessons
module Previews
class ShareController < ApplicationController
def create
@preview = LessonPreview.new(content: params[:content])

respond_to do |format|
if @preview.save
format.turbo_stream
else
flash.now[:alert] = 'Unable to share preview'

format.turbo_stream { render turbo_stream: turbo_stream.update('flash-messages', partial: 'shared/flash') }
end
end
end
end
end
end
26 changes: 3 additions & 23 deletions app/controllers/lessons/previews_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,31 +5,11 @@ def show
end

def create
preview_link = LessonPreview.new(lesson_preview_params)
@preview = LessonPreview.new(content: params[:markdown])

if preview_link.save
render json: { preview_link: lessons_preview_url(uuid: preview_link.id) }, status: :created
else
render json: { errors: preview_link.errors.full_messages }, status: :unprocessable_entity
respond_to do |format|
format.turbo_stream
end
end

def markdown
if content.present?
render json: { content: MarkdownConverter.new(params[:content]).as_html }
else
render json: { content: '<p>Nothing to preview</p>' }
end
end

private

def content
params[:content]
end

def lesson_preview_params
params.permit(:content)
end
end
end
16 changes: 16 additions & 0 deletions app/javascript/controllers/autosubmit_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Controller } from '@hotwired/stimulus';
import debounce from 'debounce';

export default class Autosubmit extends Controller {
initialize() {
this.debouncedSubmit = debounce(this.debouncedSubmit.bind(this), 300);
}

submit() {
this.element.requestSubmit();
}

debouncedSubmit() {
this.submit();
}
}
16 changes: 16 additions & 0 deletions app/javascript/controllers/clipboard_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Controller } from '@hotwired/stimulus';

export default class ClipboardController extends Controller {
static targets = ['source', 'message'];

copy() {
navigator.clipboard.writeText(this.sourceTarget.value);

if (this.hasMessageTarget) {
this.messageTarget.classList.remove('hidden');
setTimeout(() => {
this.messageTarget.classList.add('hidden');
}, 2000);
}
}
}
23 changes: 23 additions & 0 deletions app/javascript/controllers/share_preview_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Controller } from '@hotwired/stimulus';
import { post } from '@rails/request.js';

export default class SharePreviewController extends Controller {
static targets = ['input', 'button'];

static values = { url: String };

connect() {
this.inputTarget.addEventListener('input', this.toggleButton.bind(this));
}

async share(e) {
e.preventDefault();

const content = this.inputTarget.value;
await post(this.urlValue, { body: JSON.stringify({ content }) });
}

toggleButton() {
this.buttonTarget.classList.toggle('hidden', this.inputTarget.value.length === 0);
}
}
43 changes: 43 additions & 0 deletions app/javascript/controllers/tabs_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Controller } from '@hotwired/stimulus';

export default class TabsController extends Controller {
static targets = ['tab', 'panel'];

static classes = ['active', 'inactive'];

initialize() {
this.showTab();
}

change(event) {
this.index = this.tabTargets.indexOf(event.target);
this.showTab(this.index);
}

showTab() {
this.panelTargets.forEach((el, i) => {
if (i === this.index) {
el.classList.remove(this.inactiveClass);
} else {
el.classList.add(this.inactiveClass);
}
});

this.tabTargets.forEach((el, i) => {
if (i === this.index) {
el.classList.add(...this.activeClasses);
} else {
el.classList.remove(...this.activeClasses);
}
});
}

get index() {
return parseInt(this.data.get('index'), 10);
}

set index(value) {
this.data.set('index', value);
this.showTab();
}
}
9 changes: 9 additions & 0 deletions app/views/lessons/previews/_markdown_preview.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<div id="preview-container">
<%= render ContentContainerComponent.new(classes: 'pt-6') do %>
<% if markdown.present? %>
<%= MarkdownConverter.new(markdown).as_html.html_safe %>
<% else %>
<p>Nothing to preview yet!</p>
<% end %>
<% end %>
</div>
3 changes: 3 additions & 0 deletions app/views/lessons/previews/create.turbo_stream.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<%= turbo_stream.replace 'preview-container' do %>
<%= render 'lessons/previews/markdown_preview', markdown: @preview.content %>
<% end %>
18 changes: 18 additions & 0 deletions app/views/lessons/previews/share/create.turbo_stream.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<%= turbo_stream.replace 'modal' do %>
<%= render ModalComponent.new(title: 'Share preview') do %>
<div class="w-full h-14">
<div data-controller="clipboard">
<div class="relative flex items-center">
<%= text_field_tag 'preview_url', lessons_preview_url(uuid: @preview), readonly: true, data: { clipboard_target: 'source' }, class: 'py-1.5 pr-10 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' %>

<div class="absolute inset-y-0 right-0 flex py-1 pr-1">
<button class="inline-flex items-center rounded border border-gray-300 px-1 font-sans text-xs text-gray-700 hover:bg-gray-50 focus:ring-gray-400 dark:text-gray-300 dark:hover:bg-gray-700 dark:border-gray-600 dark:focus:ring-gray-600 dark:bg-transparent" data-action="click->clipboard#copy">
<%= inline_svg_tag 'icons/clipboard.svg', class: 'h-5 w-5', aria: true, title: 'clipboard', desc: 'clipboard icon' %>
</button>
</div>
</div>
<span data-clipboard-target="message" class="hidden text-green-700 dark:text-green-600 text-sm pt-1 float-right">Copied!</span>
</div>
</div>
<% end %>
<% end %>
43 changes: 38 additions & 5 deletions app/views/lessons/previews/show.html.erb
Original file line number Diff line number Diff line change
@@ -1,9 +1,42 @@
<%= title('Lesson Preview Tool') %>

<div class="page-container">
<div class="max-w-4xl mx-auto">
<h1 class="page-heading-title">Lesson Preview Tool</h1>
<p class="text-center mb-6">Paste markdown contents here to check how they'll look on the website!</p>
<%= react_component('lesson-preview/index', { sharedContent: @preview.content.to_s }) %>
<div class="bg-white dark:bg-gray-900">
<div class="page-container">
<div class="max-w-4xl mx-auto">
<h1 class="page-heading-title text-left pb-4">Markdown Preview Tool</h1>
<p class="mb-6 text-gray-500">Paste markdown here to check how it will look on the website!</p>

<div>
<div data-controller='tabs share-preview' data-share-preview-url-value="<%= lessons_preview_share_path(format: :turbo_stream) %>" data-tabs-index='0' data-tabs-active-class="text-gray-700 bg-gray-300/50 hover:bg-gray-300 dark:bg-gray-700/90" data-tabs-inactive-class="hidden">
<div class="flex justify-between">
<div class="flex items-center mb-3">
<button data-action="tabs#change" class="text-gray-600 bg-gray-300/50 hover:text-gray-800 hover:bg-gray-200 dark:bg-gray-700/40 dark:text-gray-300 rounded-md border border-transparent px-3 py-1.5 font-medium cursor-pointer focus:outline-none" data-tabs-target='tab'>
Write
</button>
<button data-action="tabs#change" class="ml-2 text-gray-600 hover:text-gray-800 hover:bg-gray-200 dark:bg-gray-700/40 dark:text-gray-300 rounded-md border border-transparent px-3 py-1.5 font-medium cursor-pointer focus:outline-none" data-tabs-target='tab'>
Preview
</button>
</div>

<%= button_to '#', class: 'hidden button button--secondary py-2 px-3', method: :get, data: { turbo_frame: 'modal', share_preview_target: 'button', action: 'share-preview#share' } do %>
<%= inline_svg_tag 'icons/share.svg', class: 'h-3 w-3 mr-2', aria: true, title: 'dismiss', desc: 'dismiss icon' %>
Share
<% end %>
</div>

<div data-tabs-target="panel" class="mx-auto">
<%= form_with url: lessons_preview_path, data: { controller: 'autosubmit', action: 'input->autosubmit#debouncedSubmit' } do |form| %>
<%= form.text_area :markdown, value: @preview.content, autofocus: true, maxlength: 70_000, placeholder: 'Enter markdown here...', data: { share_preview_target: 'input' }, class: 'w-full min-h-screen block rounded-md border-gray-300 dark:bg-gray-700/60 dark:focus:ring-2 dark:placeholder-gray-400 dark:border-gray-500 dark:focus:ring-gray-500 shadow-sm focus:border-blue-500 focus:ring-blue-500' %>
<% end %>
</div>

<div data-tabs-target="panel" class="min-h-screen">
<%= render 'lessons/previews/markdown_preview', markdown: @preview.content %>
</div>
</div>

</div>

</div>
</div>
</div>
5 changes: 4 additions & 1 deletion config/initializers/rack_attack.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ class Rack::Attack

Rack::Attack.throttle('report_ip', limit: 3, period: 60) do |request|
request.ip if request.path.ends_with?('/flags') && request.post?
request.ip if request.path.ends_with?('/preview') && request.post?
end

Rack::Attack.throttle('report_ip', limit: 5, period: 60) do |request|
request.ip if request.path.ends_with?('/preview/share.turbo_stream') && request.post?
end
end
2 changes: 1 addition & 1 deletion config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@

namespace :lessons do
resource :preview, only: %i[show create] do
post :markdown
resource :share, only: %i[create], controller: 'previews/share'
end

resources :installation_guides, only: :index
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@hotwired/stimulus": "^3.2.0",
"@hotwired/stimulus-webpack-helpers": "^1.0.1",
"@hotwired/turbo-rails": "^7.3.0",
"@rails/request.js": "^0.0.8",
"@rails/ujs": "^7.0.5",
"@sentry/browser": "^7.57.0",
"@stimulus/polyfills": "^2.0.0",
Expand All @@ -34,6 +35,7 @@
"core-js": "^3.31.1",
"css-loader": "^6.7.3",
"css-minimizer-webpack-plugin": "^5.0.1",
"debounce": "^1.2.1",
"el-transition": "^0.0.7",
"hint.css": "^2.7.0",
"js-base64": "^3.7.5",
Expand Down
26 changes: 26 additions & 0 deletions spec/requests/lessons/previews/share_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
require 'rails_helper'

RSpec.describe 'Share preview' do
describe 'POST #create' do
context 'when the preview is valid' do
it 'creates a sharable preview' do
expect do
post lessons_preview_share_path(content: '# hello', format: :turbo_stream)
end.to change { LessonPreview.count }.by(1)
end
end

context 'when the preview is invalid' do
it 'does not create a sharable preview' do
expect do
post lessons_preview_share_path(content: '', format: :turbo_stream)
end.not_to change { LessonPreview.count }
end

it 'informs the user a sharable preview cannot be created' do
post lessons_preview_share_path(content: '', format: :turbo_stream)
expect(response.body).to include('Unable to share preview')
end
end
end
end
11 changes: 5 additions & 6 deletions spec/requests/lessons/previews_spec.rb
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
require 'rails_helper'

RSpec.describe 'Preview Pages' do
RSpec.describe 'Lesson preview' do
describe 'GET #show' do
it 'renders a view' do
it 'renders the preview input page' do
get lessons_preview_path
expect(response).to have_http_status(:success)
end
end

describe 'POST #create' do
it 'saves a preview_link' do
expect do
post lessons_preview_path(content: '# hello')
end.to change { LessonPreview.count }.by(1)
it 'converts the markdown to html' do
post lessons_preview_path(markdown: 'hello world', format: :turbo_stream)
expect(response.body).to include('<p>hello world</p>')
end
end
end
1 change: 1 addition & 0 deletions tailwind.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
module.exports = {
content: [
'./app/**/*.html.erb',
'./app/**/*.turbo_stream.erb',
'./app/components/**/*',
'./app/components/*.rb',
'./app/javascript/**/*.js',
Expand Down
10 changes: 10 additions & 0 deletions yarn.lock

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

0 comments on commit 7c1cc00

Please sign in to comment.