mirror of
https://github.com/Growstuff/growstuff.git
synced 2026-10-31 19:29:26 -04:00
Merge branch 'main' into api/graphiti
This commit is contained in:
39 files changed
+2244
-66
No files matched your search
@@ -67,3 +67,300 @@
|
||||
font-family: monospace;
|
||||
}
|
||||
}
|
||||
|
||||
// The photo that leads a post's page.
|
||||
.post-card-photo {
|
||||
img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 260px;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
// "More from this author" in the post sidebar.
|
||||
.more-from-author {
|
||||
.post-compact {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.post-compact-photo {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
flex: none;
|
||||
object-fit: cover;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
// The choices in the card photo dialog.
|
||||
.card-photo-choices {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background: #f4f2ef;
|
||||
border: 1px solid rgba(65, 63, 59, .15);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.card-photo-choice {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card-photo-polaroid {
|
||||
display: block;
|
||||
padding: 6px 6px 0;
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
box-shadow: 0 1px 3px rgba(65, 63, 59, .25);
|
||||
outline: 3px solid transparent;
|
||||
outline-offset: 2px;
|
||||
|
||||
img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 90px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
span {
|
||||
display: block;
|
||||
padding: 6px 2px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
input:checked + .card-photo-polaroid {
|
||||
outline-color: #212121;
|
||||
}
|
||||
|
||||
.card-photo-star {
|
||||
color: #57803c;
|
||||
}
|
||||
|
||||
.card-photo-tick {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
left: 14px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: #212121;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
|
||||
input:checked ~ .card-photo-tick {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
input:focus-visible + .card-photo-polaroid {
|
||||
outline-color: #2f4365;
|
||||
}
|
||||
}
|
||||
|
||||
.card-photo-preview {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
|
||||
img {
|
||||
width: 200px;
|
||||
height: 130px;
|
||||
flex: none;
|
||||
object-fit: cover;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.post-card-photo-change {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 8px 16px 0;
|
||||
}
|
||||
|
||||
.post-editor {
|
||||
.post-editor-preview {
|
||||
min-height: 320px;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
// Saving and publishing, always in reach at the foot of the editor page.
|
||||
.post-save-bar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
margin-top: 24px;
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
border-top: 1px solid rgba(65, 63, 59, .35);
|
||||
box-shadow: 0 -2px 6px rgba(0, 0, 0, .08);
|
||||
}
|
||||
|
||||
// The editor page: the form, its sidebar and the save bar share one width.
|
||||
.post-edit-page {
|
||||
max-width: 1140px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
// Links to crops, members and plantings in the editor, and the list for choosing them.
|
||||
.post-editor-content .link-chip {
|
||||
padding: 0 8px;
|
||||
border: 1px solid rgba(65, 63, 59, .15);
|
||||
border-radius: 999px;
|
||||
background: #e8f5e9;
|
||||
color: #413f3b;
|
||||
font-weight: 600;
|
||||
cursor: default;
|
||||
|
||||
&.ProseMirror-selectednode {
|
||||
outline: 2px solid #2f4365;
|
||||
}
|
||||
}
|
||||
|
||||
.link-suggestions {
|
||||
z-index: 1080;
|
||||
width: 340px;
|
||||
max-height: 340px;
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(65, 63, 59, .15);
|
||||
border-radius: 4px;
|
||||
|
||||
.link-suggestions-group {
|
||||
padding: 6px 14px;
|
||||
background: #f4f2ef;
|
||||
border-bottom: 1px solid rgba(65, 63, 59, .15);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .06em;
|
||||
text-transform: uppercase;
|
||||
color: #413f3b;
|
||||
}
|
||||
|
||||
.link-suggestions-item {
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
|
||||
&.active,
|
||||
&:hover {
|
||||
background: #f4f2ef;
|
||||
}
|
||||
}
|
||||
|
||||
.link-suggestions-hint {
|
||||
padding: 8px 14px;
|
||||
border-top: 1px solid rgba(65, 63, 59, .15);
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
// Photos placed in a post's text, on the post page.
|
||||
.post-content .post-figure {
|
||||
margin: 22px 0;
|
||||
padding: 8px 8px 0;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(65, 63, 59, .25);
|
||||
max-width: 720px;
|
||||
|
||||
img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.post-figure-caption {
|
||||
margin: 0;
|
||||
padding: 8px 4px 10px;
|
||||
font-size: 13px;
|
||||
color: #413f3b;
|
||||
}
|
||||
|
||||
&.post-figure-half {
|
||||
max-width: 350px;
|
||||
}
|
||||
|
||||
// Two pair photos in a row sit side by side.
|
||||
&.post-figure-pair {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
width: calc(50% - 10px);
|
||||
max-width: 350px;
|
||||
margin-right: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
// A photo in the editor: the polaroid, with its controls beside it.
|
||||
.photo-figure {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
margin: 20px 0 8px;
|
||||
|
||||
&.photo-figure-selected .photo-figure-picture {
|
||||
outline: 2px solid #2f4365;
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.photo-figure-picture {
|
||||
flex: 1 1 320px;
|
||||
max-width: 460px;
|
||||
}
|
||||
|
||||
.photo-figure-half,
|
||||
.photo-figure-pair {
|
||||
max-width: 230px;
|
||||
}
|
||||
|
||||
.photo-figure-polaroid {
|
||||
padding: 8px 8px 0;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(65, 63, 59, .25);
|
||||
|
||||
img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.photo-figure-missing {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
background: #f4f2ef;
|
||||
}
|
||||
|
||||
.photo-figure-caption {
|
||||
padding: 8px 4px 10px;
|
||||
font-size: 13px;
|
||||
color: #413f3b;
|
||||
}
|
||||
|
||||
.photo-figure-size {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.photo-figure-controls {
|
||||
flex: 1 1 220px;
|
||||
padding: 12px 14px;
|
||||
background: #f4f2ef;
|
||||
border: 1px solid rgba(65, 63, 59, .15);
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,9 @@ class PhotosController < ApplicationController
|
||||
def render_added_json
|
||||
garden = @item.is_a?(Garden) ? @item : (@item.garden if @item.respond_to?(:garden))
|
||||
card = GardenCardSerializer.collection([garden], ability: current_ability, show_owner: false).first if garden
|
||||
render json: { photo: { id: @photo.id, url: photo_path(@photo) }, garden: card }, status: :created
|
||||
photo = { id: @photo.id, url: photo_path(@photo), title: @photo.title,
|
||||
thumbnail_url: @photo.thumbnail_url, fullsize_url: @photo.fullsize_url }
|
||||
render json: { photo:, garden: card }, status: :created
|
||||
end
|
||||
|
||||
def photo_params
|
||||
@@ -125,7 +127,10 @@ class PhotosController < ApplicationController
|
||||
item_class = params[:type].capitalize
|
||||
raise "Photos not supported" unless Photo::PHOTO_CAPABLE.include? item_class
|
||||
|
||||
item_class.constantize.find(params[:id])
|
||||
klass = item_class.constantize
|
||||
# A draft post is still its author's to add photos to.
|
||||
klass = klass.with_drafts if klass.respond_to?(:with_drafts)
|
||||
klass.find(params[:id])
|
||||
end
|
||||
|
||||
#
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
class PostsController < ApplicationController
|
||||
before_action :authenticate_member!, except: %i(index show)
|
||||
load_and_authorize_resource
|
||||
before_action :find_post, only: %i(show edit update destroy)
|
||||
load_and_authorize_resource except: %i(preview link_suggestions)
|
||||
responders :flash
|
||||
respond_to :html, :json
|
||||
respond_to :rss, only: %i(index show)
|
||||
@@ -14,7 +15,6 @@ class PostsController < ApplicationController
|
||||
end
|
||||
|
||||
def show
|
||||
@post = Post.includes(:author).find(params[:id])
|
||||
respond_with(@post)
|
||||
end
|
||||
|
||||
@@ -30,15 +30,32 @@ class PostsController < ApplicationController
|
||||
|
||||
def edit; end
|
||||
|
||||
# What the editor's "link to" list offers besides crops (which it gets from
|
||||
# /crops/search.json): members, and the plantings of the member writing.
|
||||
def link_suggestions
|
||||
authorize! :create, Post
|
||||
term = params[:term].to_s.strip
|
||||
render json: { members: member_suggestions(term), plantings: planting_suggestions(term) }
|
||||
end
|
||||
|
||||
# The body as it will look when posted, for the editor's Preview tab.
|
||||
def preview
|
||||
authorize! :create, Post
|
||||
render partial: 'posts/body', formats: [:html], locals: { body: params[:body].to_s }
|
||||
end
|
||||
|
||||
def create
|
||||
params[:post][:author_id] = current_member.id
|
||||
@post = Post.new(post_params)
|
||||
flash[:notice] = t('posts.created') if @post.save
|
||||
flash[:notice] = t(@post.draft? ? 'posts.draft_saved' : 'posts.created') if @post.save
|
||||
respond_with(@post)
|
||||
end
|
||||
|
||||
def update
|
||||
flash[:notice] = t('posts.updated') if @post.update(post_params)
|
||||
was_draft = @post.draft?
|
||||
if @post.update(post_params)
|
||||
flash[:notice] = t(was_draft && @post.published? ? 'posts.created' : 'posts.updated')
|
||||
end
|
||||
respond_with(@post)
|
||||
end
|
||||
|
||||
@@ -49,13 +66,47 @@ class PostsController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
SUGGESTIONS = 5
|
||||
|
||||
def member_suggestions(term)
|
||||
return [] if term.blank?
|
||||
|
||||
Member.kept.confirmed.where('login_name ILIKE ?', "#{Member.sanitize_sql_like(term)}%")
|
||||
.order(:login_name).limit(SUGGESTIONS).map { |member| { name: member.login_name } }
|
||||
end
|
||||
|
||||
def planting_suggestions(term)
|
||||
return [] if term.blank?
|
||||
|
||||
like = "#{Planting.sanitize_sql_like(term)}%"
|
||||
current_member.plantings.joins(:crop, :garden).includes(:crop, :garden)
|
||||
.where('crops.name ILIKE :like OR gardens.name ILIKE :like', like:)
|
||||
.order(created_at: :desc).limit(SUGGESTIONS)
|
||||
.map { |planting| { slug: planting.slug, name: planting.to_s, crop: planting.crop.name } }
|
||||
end
|
||||
|
||||
# A draft is only its author's: to anyone else it does not exist.
|
||||
def find_post
|
||||
@post = Post.with_drafts.includes(:author).find(params[:id])
|
||||
raise ActiveRecord::RecordNotFound if @post.draft? && @post.author != current_member
|
||||
end
|
||||
|
||||
def post_params
|
||||
params.require(:post).permit(:body, :subject, :author_id, :forum_id)
|
||||
params.require(:post).permit(:body, :subject, :author_id, :forum_id, :card_photo_id, :draft)
|
||||
end
|
||||
|
||||
# All of their own posts with drafts among them, or only the drafts.
|
||||
def own_posts
|
||||
return @author.posts unless @author == current_member
|
||||
|
||||
mine = Post.with_drafts.where(author: @author)
|
||||
params[:drafts].present? ? mine.where(published_at: nil) : mine
|
||||
end
|
||||
|
||||
def posts
|
||||
if @author
|
||||
@author.posts
|
||||
# Members see their own drafts on their own page.
|
||||
own_posts
|
||||
else
|
||||
Post
|
||||
end.order(created_at: :desc)
|
||||
|
||||
@@ -92,10 +92,16 @@ module ApplicationHelper
|
||||
|
||||
# A helper to replace the complex template compilation mess
|
||||
# of HAML, Tilt, and dynamic compilation with interpolated ruby.
|
||||
def markdownify(text)
|
||||
#
|
||||
# Photos placed in a post are only drawn with `figures: true`, which is for
|
||||
# the post itself, and must be given text that has already had its tags
|
||||
# stripped, as the figures are HTML.
|
||||
def markdownify(text, figures: false)
|
||||
translator = Haml::Filters::GrowstuffMarkdown.new
|
||||
text = text.to_s.dup
|
||||
translator.expand_photos!(text, figures:)
|
||||
translator.expand_crops!(text)
|
||||
translator.expand_plantings!(text)
|
||||
translator.expand_members!(text)
|
||||
text
|
||||
end
|
||||
|
||||
@@ -57,6 +57,26 @@ module PhotosHelper
|
||||
post.default_photo&.thumbnail_url || post.crops.first&.default_photo&.thumbnail_url || placeholder_image
|
||||
end
|
||||
|
||||
# What the CardPhotoDialog island needs: where to save the choice, the post's
|
||||
# photos, and enough of the post to draw how its card will look.
|
||||
def card_photo_dialog_props(post)
|
||||
{
|
||||
url: post_path(post),
|
||||
subject: post.subject,
|
||||
excerpt: truncate(strip_tags(markdownify(post.body)), length: 90, separator: ' '),
|
||||
byline: "by #{post.author} · #{l(post.created_at.to_date, format: :short)}",
|
||||
selectedId: post.card_photo_id,
|
||||
addPhotoUrl: new_photo_path_for(post),
|
||||
addPhotoLabel: photo_label(post),
|
||||
photos: post.photos.map { |p| { id: p.id, title: p.title, thumbnail_url: p.thumbnail_url, fullsize_url: p.fullsize_url } }
|
||||
}
|
||||
end
|
||||
|
||||
# A post's photos, as the editor's islands draw them.
|
||||
def post_photo_props(post)
|
||||
post.photos.map { |p| { id: p.id, title: p.title, thumbnail_url: p.thumbnail_url, fullsize_url: p.fullsize_url } }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def photo_or_placeholder(item)
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import React, {useState} from 'react';
|
||||
|
||||
import {patchJson} from '../api';
|
||||
import Modal from './Modal';
|
||||
|
||||
// A button on a post's page that opens a dialog for choosing the card photo:
|
||||
// the photo that stands for the post on the posts list, an author's page and
|
||||
// shared links. The photos are the post's own. Saving reloads, because the
|
||||
// page is server rendered.
|
||||
export default function CardPhotoDialog({
|
||||
url, photos, selectedId = null, subject, excerpt = '', byline = '', addPhotoUrl = null, addPhotoLabel = '',
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [chosenId, setChosenId] = useState(selectedId || (photos[0] && photos[0].id));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const chosen = photos.find((p) => p.id === chosenId);
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
const {ok} = await patchJson(url, {post: {card_photo_id: chosenId}});
|
||||
if (ok) {
|
||||
setTimeout(() => window.location.reload(), 0);
|
||||
return;
|
||||
}
|
||||
setSaving(false);
|
||||
setError('The photo could not be saved. Try again.');
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" className="btn btn-sm btn-outline-secondary" onClick={() => setOpen(true)}>
|
||||
Change
|
||||
</button>
|
||||
{open && (
|
||||
<Modal title={`Card photo for “${subject}”`} titleId="card-photo-title" onClose={() => setOpen(false)}>
|
||||
<div className="modal-body">
|
||||
<p className="text-muted">
|
||||
Photos come from your Flickr account. This photo stands for your post wherever it appears as a card: the
|
||||
posts list, your page and links shared elsewhere.
|
||||
</p>
|
||||
{photos.length === 0 ? (
|
||||
<p>Pick a photo from Flickr for this post first, then you can choose it here.</p>
|
||||
) : (
|
||||
<fieldset>
|
||||
<legend className="h6 text-uppercase">Photos in this post</legend>
|
||||
<div className="card-photo-choices">
|
||||
{photos.map((photo) => (
|
||||
<label key={photo.id} className="card-photo-choice">
|
||||
<input
|
||||
type="radio"
|
||||
name="card_photo"
|
||||
checked={photo.id === chosenId}
|
||||
onChange={() => setChosenId(photo.id)}
|
||||
/>
|
||||
<span className="card-photo-polaroid">
|
||||
<img src={photo.thumbnail_url} alt="" />
|
||||
<span>{photo.id === selectedId && <b className="card-photo-star">★ </b>}{photo.title}</span>
|
||||
</span>
|
||||
{/* Not colour alone: the chosen photo is also tagged and heavily outlined. */}
|
||||
<span className="card-photo-tick">✓ Selected</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
{addPhotoUrl && (
|
||||
<p className="mt-2">
|
||||
{/* The page's photo dialog opens from any [data-photo-dialog] link. */}
|
||||
<a
|
||||
href={addPhotoUrl}
|
||||
className="btn btn-sm btn-outline-secondary me-2"
|
||||
data-photo-dialog
|
||||
data-photo-label={addPhotoLabel}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Pick a different photo from Flickr
|
||||
</a>
|
||||
<span className="text-muted small">Or leave it empty and the first photo is used.</span>
|
||||
</p>
|
||||
)}
|
||||
{chosen && (
|
||||
<>
|
||||
<h3 className="h6 text-uppercase mt-3">How it will look</h3>
|
||||
<div className="card-photo-preview">
|
||||
<img src={chosen.fullsize_url} alt="Preview of the card photo" />
|
||||
<div>
|
||||
<strong>{subject}</strong>
|
||||
<p className="text-muted small mb-1">{excerpt}</p>
|
||||
<small className="text-muted">{byline}</small>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{error && <div className="alert alert-danger mt-3" role="alert">{error}</div>}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn btn-outline-secondary" onClick={() => setOpen(false)}>Cancel</button>
|
||||
<button type="button" className="btn btn-primary" disabled={!chosen || saving} onClick={save}>
|
||||
Use this photo
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import {render, screen, waitFor} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
import * as api from '../api';
|
||||
import CardPhotoDialog from './CardPhotoDialog';
|
||||
|
||||
const photos = [
|
||||
{id: 1, title: 'Fermenting jar', thumbnail_url: '/jar-thumb.jpg', fullsize_url: '/jar.jpg'},
|
||||
{id: 2, title: 'Drying on a plate', thumbnail_url: '/plate-thumb.jpg', fullsize_url: '/plate.jpg'},
|
||||
];
|
||||
|
||||
const props = {url: '/posts/7', photos, subject: 'Saving tomato seed', excerpt: 'The tomatoes', byline: 'by brenda'};
|
||||
|
||||
async function openDialog(extra = {}) {
|
||||
const user = userEvent.setup();
|
||||
render(<CardPhotoDialog {...props} {...extra} />);
|
||||
await user.click(screen.getByRole('button', {name: 'Change'}));
|
||||
return user;
|
||||
}
|
||||
|
||||
describe('CardPhotoDialog', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('is closed until Change is clicked', () => {
|
||||
render(<CardPhotoDialog {...props} />);
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lists the photos in the post', async () => {
|
||||
await openDialog();
|
||||
expect(screen.getAllByRole('radio')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('selects the current card photo', async () => {
|
||||
await openDialog({selectedId: 2});
|
||||
expect(screen.getByRole('radio', {name: /Drying on a plate/})).toBeChecked();
|
||||
});
|
||||
|
||||
it('selects the first photo when none is chosen', async () => {
|
||||
await openDialog();
|
||||
expect(screen.getByRole('radio', {name: /Fermenting jar/})).toBeChecked();
|
||||
});
|
||||
|
||||
it('previews the chosen photo', async () => {
|
||||
const user = await openDialog();
|
||||
await user.click(screen.getByRole('radio', {name: /Drying on a plate/}));
|
||||
expect(screen.getByAltText('Preview of the card photo')).toHaveAttribute('src', '/plate.jpg');
|
||||
});
|
||||
|
||||
it('saves the chosen photo on the post', async () => {
|
||||
const patch = vi.spyOn(api, 'patchJson').mockResolvedValue({ok: false, status: 422, data: null});
|
||||
const user = await openDialog();
|
||||
await user.click(screen.getByRole('radio', {name: /Drying on a plate/}));
|
||||
await user.click(screen.getByRole('button', {name: 'Use this photo'}));
|
||||
await waitFor(() => expect(patch).toHaveBeenCalledWith('/posts/7', {post: {card_photo_id: 2}}));
|
||||
});
|
||||
|
||||
it('shows an error when saving fails', async () => {
|
||||
vi.spyOn(api, 'patchJson').mockResolvedValue({ok: false, status: 422, data: null});
|
||||
const user = await openDialog();
|
||||
await user.click(screen.getByRole('button', {name: 'Use this photo'}));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('could not be saved');
|
||||
});
|
||||
|
||||
it('asks for a photo first when the post has none', async () => {
|
||||
await openDialog({photos: []});
|
||||
expect(screen.getByText(/Pick a photo from Flickr for this post first/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('closes on Cancel', async () => {
|
||||
const user = await openDialog();
|
||||
await user.click(screen.getByRole('button', {name: 'Cancel'}));
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
it('says where the photos come from', async () => {
|
||||
await openDialog();
|
||||
expect(screen.getByText(/Photos come from your Flickr account/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('links to the page photo dialog for another photo', async () => {
|
||||
await openDialog({addPhotoUrl: '/photos/new?id=7&type=post'});
|
||||
expect(screen.getByRole('link', {name: 'Pick a different photo from Flickr'})).toHaveAttribute('data-photo-dialog');
|
||||
});
|
||||
|
||||
it('offers no other photo link without an address', async () => {
|
||||
await openDialog();
|
||||
expect(screen.queryByRole('link', {name: /Flickr/})).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('tags the chosen photo as selected', async () => {
|
||||
await openDialog({selectedId: 2});
|
||||
expect(screen.getAllByText('✓ Selected')).toHaveLength(2); // shown by CSS only for the checked one
|
||||
});
|
||||
|
||||
it('stars the current card photo', async () => {
|
||||
await openDialog({selectedId: 2});
|
||||
expect(screen.getByRole('radio', {name: /★\s*Drying on a plate/})).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
|
||||
// The matches for what has been typed after an @ in the post editor, grouped
|
||||
// (Crops, Members, Yours) and floating under the cursor. The editor owns which
|
||||
// is highlighted and handles the keys; this only draws the list.
|
||||
export default function LinkSuggestionList({items, selected, rect, onChoose}) {
|
||||
if (!rect || items.length === 0) return null;
|
||||
|
||||
let lastGroup = null;
|
||||
return (
|
||||
<div
|
||||
className="link-suggestions shadow"
|
||||
style={{position: 'fixed', left: rect.left, top: rect.bottom + 4}}
|
||||
>
|
||||
<ul role="listbox" aria-label="Link to…" className="list-unstyled mb-0">
|
||||
{items.map((item, index) => {
|
||||
const heading = item.group !== lastGroup;
|
||||
lastGroup = item.group;
|
||||
return (
|
||||
<React.Fragment key={`${item.kind}-${item.slug || item.label}`}>
|
||||
{heading && <li role="presentation" className="link-suggestions-group">{item.group}</li>}
|
||||
<li
|
||||
role="option"
|
||||
aria-selected={index === selected}
|
||||
className={`link-suggestions-item${index === selected ? ' active' : ''}`}
|
||||
// mousedown, not click, so the editor keeps focus while we choose.
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
onChoose(item);
|
||||
}}
|
||||
>
|
||||
<strong>{item.label}</strong>
|
||||
{item.detail && <small className="text-muted d-block">{item.detail}</small>}
|
||||
</li>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className="link-suggestions-hint text-muted">↑↓ to choose · Enter to link · Esc to keep it as text</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {render, screen} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import {describe, expect, it, vi} from 'vitest';
|
||||
|
||||
import LinkSuggestionList from './LinkSuggestionList';
|
||||
|
||||
const items = [
|
||||
{group: 'Crops', kind: 'crop', label: 'Pumpkin'},
|
||||
{group: 'Crops', kind: 'crop', label: 'Pumpkin, Crown Prince'},
|
||||
{group: 'Members', kind: 'member', label: 'pumpkinfan'},
|
||||
{group: 'Yours', kind: 'planting', label: 'Pumpkin in Patch', slug: 'a-patch-pumpkin', detail: 'Planting'},
|
||||
];
|
||||
const rect = {left: 10, bottom: 20};
|
||||
|
||||
describe('LinkSuggestionList', () => {
|
||||
it('lists every match', () => {
|
||||
render(<LinkSuggestionList items={items} selected={0} rect={rect} onChoose={() => {}} />);
|
||||
expect(screen.getAllByRole('option')).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('says what each group is', () => {
|
||||
render(<LinkSuggestionList items={items} selected={0} rect={rect} onChoose={() => {}} />);
|
||||
expect(screen.getAllByText(/^(Crops|Members|Yours)$/)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('marks the highlighted match', () => {
|
||||
render(<LinkSuggestionList items={items} selected={2} rect={rect} onChoose={() => {}} />);
|
||||
expect(screen.getByRole('option', {name: /pumpkinfan/})).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
it('chooses a match with the mouse', async () => {
|
||||
const onChoose = vi.fn();
|
||||
render(<LinkSuggestionList items={items} selected={0} rect={rect} onChoose={onChoose} />);
|
||||
await userEvent.click(screen.getByRole('option', {name: /Pumpkin in Patch/}));
|
||||
expect(onChoose).toHaveBeenCalledWith(items[3]);
|
||||
});
|
||||
|
||||
it('draws nothing without matches', () => {
|
||||
const {container} = render(<LinkSuggestionList items={[]} selected={0} rect={rect} onChoose={() => {}} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,15 @@
|
||||
import React, {useState} from 'react';
|
||||
import React, {useMemo, useRef, useState} from 'react';
|
||||
import {EditorContent, useEditor, useEditorState} from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import {Markdown} from '@tiptap/markdown';
|
||||
|
||||
import {csrfToken, patchJson} from '../api';
|
||||
import AddPhotoModal from './AddPhotoModal';
|
||||
import LinkSuggestionList from './LinkSuggestionList';
|
||||
import {createLinkChip, createLinkSearch} from './linkChip';
|
||||
import photoFigure from './photoFigure';
|
||||
import useDraftAutosave from './useDraftAutosave';
|
||||
|
||||
const BLOCKS = [
|
||||
{value: 'paragraph', label: 'Paragraph'},
|
||||
{value: 'h2', label: 'Heading'},
|
||||
@@ -24,7 +31,7 @@ function ToolButton({label, active, onClick, children, ...rest}) {
|
||||
);
|
||||
}
|
||||
|
||||
function Toolbar({editor}) {
|
||||
function Toolbar({editor, onAddPhoto}) {
|
||||
const state = useEditorState({
|
||||
editor,
|
||||
selector: ({editor: e}) => ({
|
||||
@@ -82,30 +89,152 @@ function Toolbar({editor}) {
|
||||
>
|
||||
❝ Quote
|
||||
</ToolButton>
|
||||
{onAddPhoto && (
|
||||
<ToolButton label="Flickr photo" onClick={onAddPhoto}>+ Flickr photo</ToolButton>
|
||||
)}
|
||||
{/* Typing @ opens the list of things to link to, so this just types it. */}
|
||||
<ToolButton label="Link to…" onClick={() => editor.chain().focus().insertContent('@').run()}>
|
||||
@ Link to…
|
||||
</ToolButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const search = createLinkSearch();
|
||||
|
||||
function suggestionState(props) {
|
||||
return {items: props.items, selected: 0, rect: props.clientRect && props.clientRect(), command: props.command};
|
||||
}
|
||||
|
||||
// A rich text editor for a post body that stores markdown, so people can
|
||||
// format without knowing it. The Markdown tab shows and edits the raw text.
|
||||
// The markdown goes in a hidden field called `name`, so the surrounding form
|
||||
// submits it as it would a textarea.
|
||||
export default function PostEditor({name, value = '', id = 'post_body'}) {
|
||||
//
|
||||
// With `autosave` (see useDraftAutosave) the post is saved as a draft as it is
|
||||
// written. With `previewUrl` a Preview tab shows the body as the site will
|
||||
// render it.
|
||||
//
|
||||
// With `canAddPhoto`, the toolbar picks a Flickr photo for the post and places
|
||||
// it in the text. `photos` are the post's photos so far ({id, title,
|
||||
// thumbnail_url, fullsize_url}), `postPath` is where an existing post lives
|
||||
// (a draft not yet saved has none: it is saved first), and `cardPhotoId` is the
|
||||
// photo that stands for the post.
|
||||
export default function PostEditor({
|
||||
name, value = '', id = 'post_body', autosave = null, previewUrl = null,
|
||||
canAddPhoto = false, photos = [], postPath = null, cardPhotoId = null,
|
||||
}) {
|
||||
const [markdown, setMarkdown] = useState(value);
|
||||
const [tab, setTab] = useState('write');
|
||||
const [previewHtml, setPreviewHtml] = useState('');
|
||||
const {status, saveNow} = useDraftAutosave(markdown, autosave);
|
||||
const [notice, setNotice] = useState('');
|
||||
const [pickerUrl, setPickerUrl] = useState(null); // the add photo dialog's address, while it is open
|
||||
|
||||
// The photos and card photo the figures in the text look up as they draw.
|
||||
const photoList = useRef(photos);
|
||||
const cardId = useRef(cardPhotoId);
|
||||
const editorRef = useRef(null);
|
||||
|
||||
// Where the post lives, saving the draft first if it is a new one. Null when
|
||||
// there is nothing to save yet (it needs a title and some text).
|
||||
async function ensurePostPath() {
|
||||
return autosave ? saveNow() : postPath;
|
||||
}
|
||||
|
||||
async function makeCardPhoto(photoId) {
|
||||
const path = await ensurePostPath();
|
||||
if (!path) return false;
|
||||
const {ok} = await patchJson(path, {post: {card_photo_id: photoId}});
|
||||
if (ok) cardId.current = photoId;
|
||||
return ok;
|
||||
}
|
||||
|
||||
async function openPhotoPicker() {
|
||||
setNotice('');
|
||||
const path = await ensurePostPath();
|
||||
if (!path) {
|
||||
setNotice('The draft could not be saved, so a photo cannot be added yet. Try again.');
|
||||
return;
|
||||
}
|
||||
setPickerUrl(`/photos/new?type=post&id=${path.split('/').pop()}`);
|
||||
}
|
||||
|
||||
function photoAdded({photo}) {
|
||||
setPickerUrl(null);
|
||||
photoList.current = [...photoList.current, photo];
|
||||
// After the current line, or after the photo that is selected: inserting
|
||||
// over a selected photo would replace it, and only one could ever be added.
|
||||
const editor = editorRef.current;
|
||||
const {selection} = editor.state;
|
||||
const position = selection.node ? selection.to : selection.from;
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(position, {type: 'photoFigure', attrs: {photoId: photo.id, caption: photo.title, size: 'wide'}})
|
||||
.run();
|
||||
}
|
||||
|
||||
const figure = useMemo(() => photoFigure.configure({
|
||||
getPhoto: (photoId) => photoList.current.find((p) => p.id === photoId),
|
||||
getCardPhotoId: () => cardId.current,
|
||||
setCardPhoto: (photoId) => makeCardPhoto(photoId),
|
||||
}), []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const tabs = [['write', 'Write'], previewUrl && ['preview', 'Preview'], ['markdown', 'Markdown']].filter(Boolean);
|
||||
|
||||
async function loadPreview() {
|
||||
setPreviewHtml('');
|
||||
const response = await fetch(previewUrl, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken()},
|
||||
body: JSON.stringify({body: markdown}),
|
||||
});
|
||||
setPreviewHtml(response.ok ? await response.text() : '<p>The preview could not be loaded.</p>');
|
||||
}
|
||||
|
||||
// The matches after an @ ({items, selected, rect, command}), or null.
|
||||
const [suggest, setSuggest] = useState(null);
|
||||
const suggestRef = useRef(null);
|
||||
suggestRef.current = suggest;
|
||||
|
||||
const linkChip = useMemo(() => createLinkChip({
|
||||
suggestion: {
|
||||
items: ({query}) => search(query),
|
||||
render: () => ({
|
||||
onStart: (props) => setSuggest(suggestionState(props)),
|
||||
onUpdate: (props) => setSuggest(suggestionState(props)),
|
||||
onKeyDown: ({event}) => {
|
||||
const current = suggestRef.current;
|
||||
if (!current || current.items.length === 0) return false;
|
||||
const count = current.items.length;
|
||||
if (event.key === 'ArrowDown') setSuggest({...current, selected: (current.selected + 1) % count});
|
||||
else if (event.key === 'ArrowUp') setSuggest({...current, selected: (current.selected + count - 1) % count});
|
||||
else if (event.key === 'Enter') current.command(current.items[current.selected]);
|
||||
else return false;
|
||||
return true;
|
||||
},
|
||||
onExit: () => setSuggest(null),
|
||||
}),
|
||||
},
|
||||
}), []);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [StarterKit, Markdown],
|
||||
extensions: [StarterKit, Markdown, linkChip, figure],
|
||||
content: value,
|
||||
contentType: 'markdown',
|
||||
onUpdate: ({editor: e}) => setMarkdown(e.getMarkdown().trim()),
|
||||
editorProps: {attributes: {class: 'post-editor-content', 'aria-label': 'Post body', role: 'textbox', 'aria-multiline': 'true'}},
|
||||
});
|
||||
|
||||
editorRef.current = editor;
|
||||
|
||||
const showTab = (next) => {
|
||||
if (next === 'write' && editor) {
|
||||
editor.commands.setContent(markdown, {contentType: 'markdown'});
|
||||
}
|
||||
if (next === 'preview') loadPreview();
|
||||
setTab(next);
|
||||
};
|
||||
|
||||
@@ -113,7 +242,7 @@ export default function PostEditor({name, value = '', id = 'post_body'}) {
|
||||
<div className="post-editor">
|
||||
<input type="hidden" name={name} value={markdown} />
|
||||
<div className="post-editor-tabs" role="tablist">
|
||||
{[['write', 'Write'], ['markdown', 'Markdown']].map(([key, label]) => (
|
||||
{tabs.map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
@@ -125,14 +254,32 @@ export default function PostEditor({name, value = '', id = 'post_body'}) {
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
<span className="post-editor-status text-muted small ms-auto align-self-end" role="status" aria-live="polite">
|
||||
{notice || status}
|
||||
</span>
|
||||
</div>
|
||||
{pickerUrl && (
|
||||
<AddPhotoModal label="this post" newUrl={pickerUrl} onClose={() => setPickerUrl(null)} onAdded={photoAdded} />
|
||||
)}
|
||||
<div className="post-editor-panel">
|
||||
{tab === 'write' && editor && (
|
||||
<>
|
||||
<Toolbar editor={editor} />
|
||||
<Toolbar editor={editor} onAddPhoto={canAddPhoto ? openPhotoPicker : null} />
|
||||
<EditorContent editor={editor} id={id} />
|
||||
{suggest && (
|
||||
<LinkSuggestionList
|
||||
items={suggest.items}
|
||||
selected={suggest.selected}
|
||||
rect={suggest.rect}
|
||||
onChoose={suggest.command}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{tab === 'preview' && (
|
||||
// The server renders (and sanitises) the markdown, as it does for a posted post.
|
||||
<div className="post-editor-preview post-content" dangerouslySetInnerHTML={{__html: previewHtml}} />
|
||||
)}
|
||||
{tab === 'markdown' && (
|
||||
<textarea
|
||||
id={id}
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
import {render, screen, waitFor} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import {beforeAll, describe, expect, it} from 'vitest';
|
||||
import {afterEach, beforeAll, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
import * as api from '../api';
|
||||
|
||||
import PostEditor from './PostEditor';
|
||||
|
||||
// The Flickr picker has its own tests. Here it just hands back the next photo.
|
||||
let nextPhoto = 6;
|
||||
vi.mock('./AddPhotoModal', () => ({
|
||||
default: ({onAdded}) => (
|
||||
<div role="dialog" aria-label="Add photo">
|
||||
<button type="button" onClick={() => {
|
||||
const id = nextPhoto++;
|
||||
onAdded({photo: {id, title: `Photo ${id}`, thumbnail_url: '/t.jpg', fullsize_url: '/f.jpg'}});
|
||||
}}>
|
||||
Add one
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const hidden = (container) => container.querySelector('input[type="hidden"]');
|
||||
|
||||
async function setup(props = {}) {
|
||||
@@ -19,6 +36,7 @@ describe('PostEditor', () => {
|
||||
Range.prototype.getClientRects = rects;
|
||||
Range.prototype.getBoundingClientRect = () => ({top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0});
|
||||
Element.prototype.getClientRects = rects;
|
||||
document.elementFromPoint = () => null;
|
||||
});
|
||||
|
||||
it('submits the markdown under the given name', async () => {
|
||||
@@ -49,7 +67,7 @@ describe('PostEditor', () => {
|
||||
it('lists the formatting buttons', async () => {
|
||||
await setup();
|
||||
const names = screen.getAllByRole('button').map((b) => b.getAttribute('aria-label'));
|
||||
expect(names).toEqual(['Bold', 'Italic', 'Link', 'Bulleted list', 'Quote']);
|
||||
expect(names).toEqual(['Bold', 'Italic', 'Link', 'Bulleted list', 'Quote', 'Link to…']);
|
||||
});
|
||||
|
||||
it('updates the markdown when a list is started', async () => {
|
||||
@@ -104,4 +122,231 @@ describe('PostEditor', () => {
|
||||
expect(hidden(container)).toHaveValue('Grow [tomato](crop) here');
|
||||
});
|
||||
});
|
||||
describe('Preview tab', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('is not offered without a preview address', async () => {
|
||||
await setup();
|
||||
expect(screen.queryByRole('tab', {name: 'Preview'})).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the body as the server renders it', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ok: true, text: async () => '<p>Rendered <strong>body</strong></p>'}));
|
||||
await setup({value: 'Hello', previewUrl: '/posts/preview'});
|
||||
await userEvent.click(screen.getByRole('tab', {name: 'Preview'}));
|
||||
expect(await screen.findByText('body')).toBeInTheDocument();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
describe('autosave', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const autosave = {createUrl: '/posts', updateUrl: '/posts/a-draft', subjectId: 'post_subject'};
|
||||
|
||||
async function typeBody(text) {
|
||||
const {container} = await setup({autosave});
|
||||
const input = document.createElement('input');
|
||||
input.id = 'post_subject';
|
||||
input.value = 'A title';
|
||||
document.body.appendChild(input);
|
||||
await userEvent.click(screen.getByRole('tab', {name: 'Markdown'}));
|
||||
await userEvent.type(screen.getByRole('textbox', {name: 'Post body as markdown'}), text);
|
||||
return {container, input};
|
||||
}
|
||||
|
||||
it('saves the draft after typing stops', async () => {
|
||||
const patch = vi.spyOn(api, 'patchJson').mockResolvedValue({ok: true, status: 200, data: null});
|
||||
const {input} = await typeBody('words');
|
||||
await waitFor(() => expect(patch).toHaveBeenCalledWith(
|
||||
'/posts/a-draft', {post: {subject: 'A title', body: 'words', draft: true}}
|
||||
), {timeout: 3000});
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it('says when the draft is saved', async () => {
|
||||
vi.spyOn(api, 'patchJson').mockResolvedValue({ok: true, status: 200, data: null});
|
||||
const {input} = await typeBody('words');
|
||||
expect(await screen.findByText(/Draft saved/, {}, {timeout: 3000})).toBeInTheDocument();
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it('says when saving fails', async () => {
|
||||
vi.spyOn(api, 'patchJson').mockResolvedValue({ok: false, status: 500, data: null});
|
||||
const {input} = await typeBody('words');
|
||||
expect(await screen.findByText(/Could not save the draft/, {}, {timeout: 3000})).toBeInTheDocument();
|
||||
input.remove();
|
||||
});
|
||||
|
||||
it('saves a draft with text but no title yet', async () => {
|
||||
const patch = vi.spyOn(api, 'patchJson').mockResolvedValue({ok: true, status: 200, data: null});
|
||||
await setup({autosave});
|
||||
await userEvent.click(screen.getByRole('tab', {name: 'Markdown'}));
|
||||
await userEvent.type(screen.getByRole('textbox', {name: 'Post body as markdown'}), 'words');
|
||||
await waitFor(() => expect(patch).toHaveBeenCalledWith(
|
||||
'/posts/a-draft', {post: {subject: '', body: 'words', draft: true}}
|
||||
), {timeout: 3000});
|
||||
});
|
||||
|
||||
it('does not save when nothing is written', async () => {
|
||||
const patch = vi.spyOn(api, 'patchJson').mockResolvedValue({ok: true, status: 200, data: null});
|
||||
await setup({autosave});
|
||||
await userEvent.click(screen.getByRole('tab', {name: 'Markdown'}));
|
||||
await userEvent.click(screen.getByRole('tab', {name: 'Write'}));
|
||||
await new Promise((resolve) => setTimeout(resolve, 1800));
|
||||
expect(patch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('links to crops, members and plantings', () => {
|
||||
const roundTrip = async (markdown) => {
|
||||
const {container} = await setup({value: markdown});
|
||||
await userEvent.click(screen.getByRole('tab', {name: 'Markdown'}));
|
||||
await userEvent.click(screen.getByRole('tab', {name: 'Write'}));
|
||||
return hidden(container);
|
||||
};
|
||||
|
||||
it('shows a crop link as a chip', async () => {
|
||||
await setup({value: 'Grow [tomato](crop) here'});
|
||||
expect(await screen.findByText('@tomato')).toHaveClass('link-chip-crop');
|
||||
});
|
||||
|
||||
it('shows a member link as a chip', async () => {
|
||||
await setup({value: 'Thanks [jane](member)'});
|
||||
expect(await screen.findByText('@jane')).toHaveClass('link-chip-member');
|
||||
});
|
||||
|
||||
it('shows a planting link as a chip', async () => {
|
||||
await setup({value: 'See [my tomato](planting:jane-garden-tomato)'});
|
||||
expect(await screen.findByText('@my tomato')).toHaveClass('link-chip-planting');
|
||||
});
|
||||
|
||||
it('keeps a member link as written', async () => {
|
||||
expect(await roundTrip('Thanks [jane](member)')).toHaveValue('Thanks [jane](member)');
|
||||
});
|
||||
|
||||
it('keeps a planting link as written', async () => {
|
||||
expect(await roundTrip('See [my tomato](planting:jane-garden-tomato)')).toHaveValue(
|
||||
'See [my tomato](planting:jane-garden-tomato)'
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves ordinary links alone', async () => {
|
||||
expect(await roundTrip('A [link](http://example.com) here')).toHaveValue('A [link](http://example.com) here');
|
||||
});
|
||||
|
||||
it('types an @ from the Link to button', async () => {
|
||||
const {container} = await setup();
|
||||
await userEvent.click(screen.getByRole('button', {name: 'Link to…'}));
|
||||
await waitFor(() => expect(hidden(container).value).toMatch(/@/));
|
||||
});
|
||||
});
|
||||
|
||||
describe('photos in the text', () => {
|
||||
const photos = [{id: 5, title: 'Seed jar', thumbnail_url: '/t.jpg', fullsize_url: '/jar.jpg'}];
|
||||
const markdown = 'Before\n\n\n\nAfter';
|
||||
|
||||
it('shows a photo with its caption', async () => {
|
||||
await setup({value: markdown, photos});
|
||||
expect(await screen.findByRole('img', {name: 'Day two'})).toHaveAttribute('src', '/jar.jpg');
|
||||
});
|
||||
|
||||
it('marks the current size', async () => {
|
||||
await setup({value: markdown, photos});
|
||||
expect(await screen.findByRole('button', {name: 'Half', pressed: true})).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the photo as written when switching tabs', async () => {
|
||||
const {container} = await setup({value: markdown, photos});
|
||||
await screen.findByRole('img', {name: 'Day two'});
|
||||
await userEvent.click(screen.getByRole('tab', {name: 'Markdown'}));
|
||||
await userEvent.click(screen.getByRole('tab', {name: 'Write'}));
|
||||
expect(hidden(container)).toHaveValue(markdown);
|
||||
});
|
||||
|
||||
it('changes the size in the markdown', async () => {
|
||||
const {container} = await setup({value: markdown, photos});
|
||||
await userEvent.click(await screen.findByRole('button', {name: 'Side by side'}));
|
||||
await waitFor(() => expect(hidden(container).value).toContain('(photo:5/pair)'));
|
||||
});
|
||||
|
||||
it('changes the caption in the markdown', async () => {
|
||||
const {container} = await setup({value: markdown, photos});
|
||||
const caption = await screen.findByLabelText('Caption');
|
||||
await userEvent.clear(caption);
|
||||
await userEvent.type(caption, 'Fresh');
|
||||
await waitFor(() => expect(hidden(container).value).toContain(''));
|
||||
});
|
||||
|
||||
it('removes the photo from the text', async () => {
|
||||
const {container} = await setup({value: markdown, photos});
|
||||
await userEvent.click(await screen.findByRole('button', {name: 'Remove'}));
|
||||
await waitFor(() => expect(hidden(container).value).not.toContain('photo:5'));
|
||||
});
|
||||
|
||||
it('marks the card photo', async () => {
|
||||
await setup({value: markdown, photos, cardPhotoId: 5});
|
||||
expect(await screen.findByRole('button', {name: '★ Card photo', pressed: true})).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('draws a placeholder for a photo it does not know', async () => {
|
||||
await setup({value: ''});
|
||||
expect(await screen.findByText('Photo 99')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers the Flickr photo button only to those who can add photos', async () => {
|
||||
await setup();
|
||||
expect(screen.queryByRole('button', {name: 'Flickr photo'})).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('saves a blank new draft so a photo has a post to go on', async () => {
|
||||
const create = vi.spyOn(api, 'postJson').mockResolvedValue({ok: true, status: 201, data: {id: 12}});
|
||||
await setup({canAddPhoto: true, autosave: {createUrl: '/posts', updateUrl: null, subjectId: 'post_subject'}});
|
||||
await userEvent.click(screen.getByRole('button', {name: 'Flickr photo'}));
|
||||
await waitFor(() => expect(create).toHaveBeenCalledWith('/posts', {post: {subject: '', body: '', draft: true}}));
|
||||
create.mockRestore();
|
||||
});
|
||||
|
||||
it('opens the picker once the draft is saved', async () => {
|
||||
vi.spyOn(api, 'postJson').mockResolvedValue({ok: true, status: 201, data: {id: 12}});
|
||||
await setup({canAddPhoto: true, autosave: {createUrl: '/posts', updateUrl: null, subjectId: 'post_subject'}});
|
||||
await userEvent.click(screen.getByRole('button', {name: 'Flickr photo'}));
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('says so when the draft cannot be saved', async () => {
|
||||
vi.spyOn(api, 'postJson').mockResolvedValue({ok: false, status: 500, data: null});
|
||||
await setup({canAddPhoto: true, autosave: {createUrl: '/posts', updateUrl: null, subjectId: 'post_subject'}});
|
||||
await userEvent.click(screen.getByRole('button', {name: 'Flickr photo'}));
|
||||
expect(await screen.findByText(/could not be saved, so a photo cannot be added/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the Flickr picker for a post that exists', async () => {
|
||||
await setup({canAddPhoto: true, postPath: '/posts/a-post'});
|
||||
await userEvent.click(screen.getByRole('button', {name: 'Flickr photo'}));
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('puts a picked photo in the text', async () => {
|
||||
const {container} = await setup({canAddPhoto: true, postPath: '/posts/a-post'});
|
||||
await userEvent.click(screen.getByRole('button', {name: 'Flickr photo'}));
|
||||
await userEvent.click(await screen.findByRole('button', {name: 'Add one'}));
|
||||
await waitFor(() => expect(hidden(container).value).toMatch(/\(photo:\d+\/wide\)/));
|
||||
});
|
||||
|
||||
it('keeps the first photo when a second is picked while it is selected', async () => {
|
||||
const {container} = await setup({canAddPhoto: true, postPath: '/posts/a-post', value: 'Words'});
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
await userEvent.click(screen.getByRole('button', {name: 'Flickr photo'}));
|
||||
await userEvent.click(await screen.findByRole('button', {name: 'Add one'}));
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
// Clicking a photo in the text selects it, as it is right after being placed.
|
||||
await userEvent.click(await screen.findByRole('img', {name: `Photo ${nextPhoto - 1}`}));
|
||||
}
|
||||
await waitFor(() => expect(hidden(container).value.match(/photo:\d+/g)).toHaveLength(2));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import {mergeAttributes, Node} from '@tiptap/core';
|
||||
import {PluginKey} from '@tiptap/pm/state';
|
||||
import Suggestion from '@tiptap/suggestion';
|
||||
|
||||
import {getJson} from '../api';
|
||||
|
||||
// How each kind of link is written in a post's markdown. Crops and members are
|
||||
// found by name, plantings by slug, as the server expands them (see
|
||||
// Haml::Filters::GrowstuffMarkdown).
|
||||
const WRITTEN = {
|
||||
crop: () => 'crop',
|
||||
member: () => 'member',
|
||||
planting: ({slug}) => `planting:${slug}`,
|
||||
};
|
||||
|
||||
const PATTERN = /^\[([^[\]]+)\]\((crop|member|planting:([\w-]+))\)/;
|
||||
|
||||
const clean = (label) => String(label).replace(/[[\]]/g, '');
|
||||
|
||||
// A link to a crop, a member or one of your plantings, shown in the text as a
|
||||
// chip, and written as [name](crop), [name](member) or [name](planting:slug).
|
||||
// It is added by typing @ and choosing from the list (see `suggestion`).
|
||||
export function createLinkChip({suggestion}) {
|
||||
return Node.create({
|
||||
name: 'linkChip',
|
||||
group: 'inline',
|
||||
inline: true,
|
||||
atom: true,
|
||||
selectable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
kind: {default: 'crop', parseHTML: (el) => el.getAttribute('data-kind')},
|
||||
label: {default: '', parseHTML: (el) => el.getAttribute('data-label')},
|
||||
slug: {default: null, parseHTML: (el) => el.getAttribute('data-slug')},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{tag: 'span[data-link-chip]'}];
|
||||
},
|
||||
|
||||
renderHTML({node, HTMLAttributes}) {
|
||||
return [
|
||||
'span',
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
'data-link-chip': '',
|
||||
'data-kind': node.attrs.kind,
|
||||
'data-label': node.attrs.label,
|
||||
'data-slug': node.attrs.slug,
|
||||
'class': `link-chip link-chip-${node.attrs.kind}`,
|
||||
}),
|
||||
`@${node.attrs.label}`,
|
||||
];
|
||||
},
|
||||
|
||||
renderText({node}) {
|
||||
return `@${node.attrs.label}`;
|
||||
},
|
||||
|
||||
markdownTokenizer: {
|
||||
name: 'linkChip',
|
||||
level: 'inline',
|
||||
start: (src) => src.search(/\[[^[\]]+\]\((?:crop|member|planting:[\w-]+)\)/),
|
||||
tokenize(src) {
|
||||
const match = PATTERN.exec(src);
|
||||
if (!match) return undefined;
|
||||
const kind = match[2].split(':')[0];
|
||||
return {type: 'linkChip', raw: match[0], kind, label: match[1], slug: match[3] || null};
|
||||
},
|
||||
},
|
||||
|
||||
parseMarkdown(token) {
|
||||
return {type: 'linkChip', attrs: {kind: token.kind, label: token.label, slug: token.slug}};
|
||||
},
|
||||
|
||||
renderMarkdown(node) {
|
||||
const {kind, label, slug} = node.attrs;
|
||||
return `[${clean(label)}](${WRITTEN[kind]({slug})})`;
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion({
|
||||
editor: this.editor,
|
||||
pluginKey: new PluginKey('linkChipSuggestion'),
|
||||
char: '@',
|
||||
allowSpaces: false,
|
||||
command: ({editor, range, props}) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(range, [
|
||||
{type: 'linkChip', attrs: {kind: props.kind, label: clean(props.label), slug: props.slug || null}},
|
||||
{type: 'text', text: ' '},
|
||||
])
|
||||
.run();
|
||||
},
|
||||
...suggestion,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const PER_GROUP = 5;
|
||||
const SEARCH_DELAY = 200; // ms to wait for typing to pause before searching
|
||||
|
||||
// What @ can link to matching `term`: crops, members, and your own plantings.
|
||||
// Each is {group, kind, label, slug?, detail?}.
|
||||
export async function fetchLinkItems(term, {signal} = {}) {
|
||||
const query = encodeURIComponent(term);
|
||||
const [crops, others] = await Promise.all([
|
||||
getJson(`/crops/search.json?term=${query}`, {signal}),
|
||||
getJson(`/posts/link_suggestions.json?term=${query}`, {signal}),
|
||||
]);
|
||||
|
||||
const items = [];
|
||||
if (crops.ok && Array.isArray(crops.data)) {
|
||||
crops.data.slice(0, PER_GROUP).forEach((crop) => {
|
||||
items.push({group: 'Crops', kind: 'crop', label: crop.name, detail: crop.matched_alternate_name});
|
||||
});
|
||||
}
|
||||
if (others.ok && others.data) {
|
||||
(others.data.members || []).forEach((member) => {
|
||||
items.push({group: 'Members', kind: 'member', label: member.name});
|
||||
});
|
||||
(others.data.plantings || []).forEach((planting) => {
|
||||
items.push({group: 'Yours', kind: 'planting', label: planting.name, slug: planting.slug, detail: 'Planting'});
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
// The `items` for the suggestion: searches once typing pauses. A search that a
|
||||
// newer one has overtaken answers with the last results, so they never go back.
|
||||
export function createLinkSearch() {
|
||||
let latest = 0;
|
||||
let last = [];
|
||||
return async function search(term) {
|
||||
if (!term.trim()) return [];
|
||||
const id = ++latest;
|
||||
await new Promise((resolve) => setTimeout(resolve, SEARCH_DELAY));
|
||||
if (id !== latest) return last;
|
||||
const items = await fetchLinkItems(term);
|
||||
if (id !== latest) return last;
|
||||
last = items;
|
||||
return items;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import React, {useState} from 'react';
|
||||
import {Node} from '@tiptap/core';
|
||||
import {NodeViewWrapper, ReactNodeViewRenderer} from '@tiptap/react';
|
||||
|
||||
// What each size looks like, as a tiny picture, so it never rests on words alone.
|
||||
const SIZES = [
|
||||
['wide', 'Wide', <rect key="a" x="1" y="1" width="22" height="12" rx="1" fill="currentColor" />],
|
||||
['half', 'Half', [
|
||||
<rect key="a" x="1" y="1" width="13" height="12" rx="1" fill="currentColor" />,
|
||||
<path key="b" d="M17 4h6M17 7h6M17 10h6" stroke="currentColor" strokeWidth="1.5" opacity=".6" />,
|
||||
]],
|
||||
['pair', 'Side by side', [
|
||||
<rect key="a" x="1" y="1" width="10" height="12" rx="1" fill="currentColor" />,
|
||||
<rect key="b" x="13" y="1" width="10" height="12" rx="1" fill="currentColor" />,
|
||||
]],
|
||||
];
|
||||
|
||||
//  or , alone on its line, as
|
||||
// the server reads it (see Haml::Filters::GrowstuffMarkdown).
|
||||
const PATTERN = /^!\[([^[\]]*)\]\(photo:(\d+)(?:\/(wide|half|pair))?\)[ \t]*(?:\n|$)/;
|
||||
|
||||
const clean = (caption) => String(caption || '').replace(/[[\]\r\n]+/g, ' ').trim();
|
||||
|
||||
// The photo in the text, with the controls beside it: caption, size, which
|
||||
// photo stands for the post, and removing it from the text.
|
||||
function PhotoFigureView({node, updateAttributes, deleteNode, selected, extension}) {
|
||||
const {getPhoto, getCardPhotoId, setCardPhoto} = extension.options;
|
||||
const {photoId, caption, size} = node.attrs;
|
||||
const photo = getPhoto(photoId);
|
||||
const [cardPhotoId, setCardPhotoId] = useState(getCardPhotoId());
|
||||
const isCard = cardPhotoId === photoId;
|
||||
|
||||
async function makeCard() {
|
||||
if (await setCardPhoto(photoId)) setCardPhotoId(photoId);
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className={`photo-figure${selected ? ' photo-figure-selected' : ''}`} contentEditable={false}>
|
||||
<div className={`photo-figure-picture photo-figure-${size}`}>
|
||||
<div className="photo-figure-polaroid">
|
||||
{photo
|
||||
? <img src={photo.fullsize_url} alt={caption || photo.title} />
|
||||
: <div className="photo-figure-missing">Photo {photoId}</div>}
|
||||
<div className="photo-figure-caption">{caption}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="photo-figure-controls">
|
||||
<label className="fw-bold small d-block">
|
||||
Caption
|
||||
<input
|
||||
className="form-control form-control-sm"
|
||||
value={caption}
|
||||
onChange={(event) => updateAttributes({caption: clean(event.target.value)})}
|
||||
/>
|
||||
</label>
|
||||
<div className="my-2">
|
||||
<span className="fw-bold small d-block mb-1" id={`size-${photoId}`}>How big in the post</span>
|
||||
<div className="btn-group btn-group-sm d-flex" role="group" aria-labelledby={`size-${photoId}`}>
|
||||
{SIZES.map(([value, label, shapes]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className={`btn btn-outline-secondary photo-figure-size${size === value ? ' active' : ''}`}
|
||||
aria-pressed={size === value}
|
||||
onClick={() => updateAttributes({size: value})}
|
||||
>
|
||||
<svg width="24" height="14" viewBox="0 0 24 14" aria-hidden="true">{shapes}</svg>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-muted small">Side by side sits this photo next to the one after it.</span>
|
||||
</div>
|
||||
<div className="d-flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-sm btn-outline-secondary${isCard ? ' active' : ''}`}
|
||||
aria-pressed={isCard}
|
||||
onClick={makeCard}
|
||||
>
|
||||
★ Card photo
|
||||
</button>
|
||||
<button type="button" className="btn btn-sm btn-outline-secondary" onClick={deleteNode}>Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// A Flickr photo in the body, drawn as a polaroid with its caption. Options:
|
||||
// getPhoto(id) finds a photo ({fullsize_url, title}), getCardPhotoId() says which
|
||||
// photo is the post's card photo, and setCardPhoto(id) makes one, resolving to
|
||||
// whether it worked.
|
||||
export default Node.create({
|
||||
name: 'photoFigure',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
|
||||
addOptions() {
|
||||
return {getPhoto: () => null, getCardPhotoId: () => null, setCardPhoto: async () => false};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
photoId: {default: null, parseHTML: (el) => Number(el.getAttribute('data-photo-id'))},
|
||||
caption: {default: '', parseHTML: (el) => el.getAttribute('data-caption') || ''},
|
||||
size: {default: 'wide', parseHTML: (el) => el.getAttribute('data-size') || 'wide'},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{tag: 'div[data-photo-figure]'}];
|
||||
},
|
||||
|
||||
renderHTML({node}) {
|
||||
return ['div', {
|
||||
'data-photo-figure': '',
|
||||
'data-photo-id': node.attrs.photoId,
|
||||
'data-caption': node.attrs.caption,
|
||||
'data-size': node.attrs.size,
|
||||
}];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(PhotoFigureView);
|
||||
},
|
||||
|
||||
markdownTokenizer: {
|
||||
name: 'photoFigure',
|
||||
level: 'block',
|
||||
start: (src) => src.search(/^!\[[^[\]]*\]\(photo:\d+/m),
|
||||
tokenize(src) {
|
||||
const match = PATTERN.exec(src);
|
||||
if (!match) return undefined;
|
||||
return {type: 'photoFigure', raw: match[0], caption: match[1], photoId: Number(match[2]), size: match[3] || 'wide'};
|
||||
},
|
||||
},
|
||||
|
||||
parseMarkdown(token) {
|
||||
return {type: 'photoFigure', attrs: {photoId: token.photoId, caption: token.caption, size: token.size}};
|
||||
},
|
||||
|
||||
renderMarkdown(node) {
|
||||
const {photoId, caption, size} = node.attrs;
|
||||
return ``;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
|
||||
import {patchJson, postJson} from '../api';
|
||||
|
||||
const DELAY = 1500;
|
||||
|
||||
function savedAt() {
|
||||
return new Date().toLocaleTimeString([], {hour: 'numeric', minute: '2-digit'});
|
||||
}
|
||||
|
||||
// Saves a post as a draft shortly after its title or body stops changing.
|
||||
// `autosave` is {createUrl, updateUrl, subjectId}: a post that does not exist
|
||||
// yet is created on the first save, after which the form is pointed at it so
|
||||
// that submitting it later updates the draft rather than making another.
|
||||
// Typing saves nothing until there is a title or some text; a photo is placed
|
||||
// by saving straight away (`saveNow`), blank or not, as it needs a post to go on.
|
||||
// Returns {status, saveNow}: the status line to show ('', 'Saving…', '✓ Draft
|
||||
// saved 10:21 am', or an error), and a function that saves straight away.
|
||||
export default function useDraftAutosave(markdown, autosave) {
|
||||
const [status, setStatus] = useState('');
|
||||
const url = useRef(autosave && autosave.updateUrl);
|
||||
const first = useRef(true);
|
||||
const [subjectTick, setSubjectTick] = useState(0);
|
||||
const latest = useRef({});
|
||||
latest.current = {markdown, autosave};
|
||||
|
||||
const subjectId = autosave && autosave.subjectId;
|
||||
useEffect(() => {
|
||||
const input = subjectId && document.getElementById(subjectId);
|
||||
if (!input) return undefined;
|
||||
const onInput = () => setSubjectTick((n) => n + 1);
|
||||
input.addEventListener('input', onInput);
|
||||
return () => input.removeEventListener('input', onInput);
|
||||
}, [subjectId]);
|
||||
|
||||
// Saves the draft now: updates it, or creates it the first time. Resolves to
|
||||
// the draft's address, or null when it could not be saved. Unless `force`,
|
||||
// a draft with no title and no text is left unsaved.
|
||||
async function save(force = false) {
|
||||
const config = latest.current.autosave;
|
||||
const input = document.getElementById(config.subjectId);
|
||||
const subject = input ? input.value.trim() : '';
|
||||
const body = latest.current.markdown;
|
||||
if (!force && !subject && !body) return url.current;
|
||||
|
||||
const form = input && input.form;
|
||||
const forum = form && form.querySelector('[name="post[forum_id]"]');
|
||||
const payload = {post: {subject, body, draft: true}};
|
||||
if (forum && forum.value) payload.post.forum_id = forum.value;
|
||||
|
||||
setStatus('Saving…');
|
||||
const result = url.current
|
||||
? await patchJson(url.current, payload)
|
||||
: await postJson(config.createUrl, payload);
|
||||
if (!result.ok) {
|
||||
setStatus('Could not save the draft. Your work is still here.');
|
||||
return url.current; // null for a draft that was never saved
|
||||
}
|
||||
if (!url.current && result.data) {
|
||||
// By id: a draft's slug follows its title, so it can change.
|
||||
url.current = `${config.createUrl}/${result.data.id}`;
|
||||
pointFormAt(form, url.current);
|
||||
}
|
||||
setStatus(`✓ Draft saved ${savedAt()}`);
|
||||
return url.current;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!autosave) return undefined;
|
||||
if (first.current) {
|
||||
first.current = false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => save(false), DELAY);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [markdown, subjectTick]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return {status, saveNow: () => save(true)};
|
||||
}
|
||||
|
||||
function pointFormAt(form, url) {
|
||||
if (!form) return;
|
||||
form.action = url;
|
||||
let method = form.querySelector('input[name="_method"]');
|
||||
if (!method) {
|
||||
method = document.createElement('input');
|
||||
method.type = 'hidden';
|
||||
method.name = '_method';
|
||||
form.appendChild(method);
|
||||
}
|
||||
method.value = 'patch';
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
// To add a component: create it in ./components and register it below.
|
||||
import AlternateNames from './components/AlternateNames';
|
||||
import AuthDialog from './components/AuthDialog';
|
||||
import CardPhotoDialog from './components/CardPhotoDialog';
|
||||
import FeedMore from './components/FeedMore';
|
||||
import GardenCards from './components/GardenCards';
|
||||
import GardenLayout from './components/GardenLayout';
|
||||
@@ -29,7 +30,7 @@ import ScientificNames from './components/ScientificNames';
|
||||
import SeedStack from './components/SeedStack';
|
||||
import {mountIslands, registerIslands} from './islands';
|
||||
|
||||
registerIslands({AlternateNames, AuthDialog, FeedMore, GardenCards, GardenLayout, GardenPicker, GrowingTable, HarvestChart, JobPicker, LocationPicker, NurseryChip, NurseryPlanter, PhotoDialog, PhotoPicker, PlantPicker, PlantingPicker, PlantingActions, PlantingRating, PostEditor, RecordDialog, ScientificNames, SeedLibrary, SeedStack});
|
||||
registerIslands({AlternateNames, AuthDialog, CardPhotoDialog, FeedMore, GardenCards, GardenLayout, GardenPicker, GrowingTable, HarvestChart, JobPicker, LocationPicker, NurseryChip, NurseryPlanter, PhotoDialog, PhotoPicker, PlantPicker, PlantingPicker, PlantingActions, PlantingRating, PostEditor, RecordDialog, ScientificNames, SeedLibrary, SeedStack});
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => mountIslands());
|
||||
|
||||
+62
-4
@@ -11,22 +11,63 @@ class Post < ApplicationRecord
|
||||
# Relationships
|
||||
belongs_to :author, class_name: 'Member', inverse_of: :posts
|
||||
belongs_to :forum, optional: true
|
||||
belongs_to :card_photo, class_name: 'Photo', optional: true, inverse_of: false
|
||||
has_many :comments, as: :commentable, dependent: :destroy
|
||||
has_many :crop_posts, dependent: :delete_all
|
||||
has_many :crops, through: :crop_posts
|
||||
has_many :notifications, as: :notifiable, dependent: :destroy
|
||||
|
||||
after_create :send_notification
|
||||
#
|
||||
# Triggers
|
||||
before_save :set_published_at
|
||||
after_save :update_crop_posts_association
|
||||
after_save :send_notification, if: -> { saved_change_to_published_at? && published? }
|
||||
|
||||
default_scope { joins(:author).merge(Member.kept) } # Ensures the owner still exists
|
||||
# Drafts have no published_at and are left out of every list and lookup, so
|
||||
# they cannot leak. The author's own pages ask for them with `with_drafts`.
|
||||
default_scope { joins(:author).merge(Member.kept).where.not(published_at: nil) } # Kept: ensures the owner still exists
|
||||
scope :with_drafts, -> { unscope(where: :published_at) }
|
||||
|
||||
# Not stored. `true` saves a draft, `false` publishes one, and nil (the
|
||||
# default) publishes a new post and leaves an existing one as it is.
|
||||
attribute :draft, :boolean
|
||||
|
||||
#
|
||||
# Validations
|
||||
validates :subject, presence: true, length: { maximum: 255 }
|
||||
validates :body, presence: true
|
||||
# A draft can be saved blank (a photo needs a saved post to go on); a post
|
||||
# being published needs both.
|
||||
validates :subject, presence: true, if: :publishing?
|
||||
validates :subject, length: { maximum: 255 }
|
||||
validates :body, presence: true, if: :publishing?
|
||||
validate :card_photo_is_in_post, if: :card_photo_id?
|
||||
|
||||
WORDS_PER_MINUTE = 200
|
||||
|
||||
# The card photo the author chose, else the most liked photo on the post.
|
||||
def default_photo
|
||||
card_photo || most_liked_photo
|
||||
end
|
||||
|
||||
def published?
|
||||
published_at.present?
|
||||
end
|
||||
|
||||
def draft?
|
||||
!published?
|
||||
end
|
||||
|
||||
def read_time_minutes
|
||||
[(body.to_s.split.size.to_f / WORDS_PER_MINUTE).ceil, 1].max
|
||||
end
|
||||
|
||||
def more_from_author(limit = 3)
|
||||
author.posts.where.not(id:).order(created_at: :desc).limit(limit)
|
||||
end
|
||||
|
||||
# A draft's address follows its title until it is published.
|
||||
def should_generate_new_friendly_id?
|
||||
slug.blank? || (draft? && subject_changed?)
|
||||
end
|
||||
|
||||
def author_date_subject
|
||||
return unless author
|
||||
@@ -65,6 +106,23 @@ class Post < ApplicationRecord
|
||||
|
||||
private
|
||||
|
||||
# Being published by this save: a published post stays so, a new one is
|
||||
# unless it is a draft, and a draft is when asked with `draft: false`.
|
||||
def publishing?
|
||||
return true if published?
|
||||
return false if draft == true
|
||||
|
||||
new_record? || draft == false
|
||||
end
|
||||
|
||||
def set_published_at
|
||||
self.published_at = Time.current if !published? && publishing?
|
||||
end
|
||||
|
||||
def card_photo_is_in_post
|
||||
errors.add(:card_photo, :invalid) unless photos.exists?(card_photo_id)
|
||||
end
|
||||
|
||||
def update_crop_posts_association
|
||||
crops.clear
|
||||
# look for crops mentioned in the post. eg. [tomato](crop)
|
||||
|
||||
@@ -4,8 +4,12 @@
|
||||
/ %h2.card-title #{model} links
|
||||
- mine = url_for([current_member, model])
|
||||
- everyones = url_for(model)
|
||||
= link_to mine, class: 'nav-link tab', aria: { current: ('page' if current_page?(mine)) } do
|
||||
= link_to mine, class: 'nav-link tab', aria: { current: ('page' if current_page?(mine) && params[:drafts].blank?) } do
|
||||
My #{model.model_name.human.pluralize}
|
||||
- if model == Post
|
||||
- drafts = member_posts_path(current_member, drafts: 1)
|
||||
= link_to drafts, class: 'nav-link tab', aria: { current: ('page' if params[:drafts].present?) } do
|
||||
= t('.my_drafts')
|
||||
= link_to everyones, class: 'nav-link tab', aria: { current: ('page' if current_page?(everyones)) } do
|
||||
Everyone's #{model.model_name.human.pluralize}
|
||||
- if can?(:create, model)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.post-content
|
||||
:markdown
|
||||
#{ markdownify(strip_tags(body), figures: true) }
|
||||
@@ -2,39 +2,103 @@
|
||||
- content_for :scripts do
|
||||
= javascript_include_tag 'react_islands', defer: true
|
||||
|
||||
.card.col-md-8.col-lg-7.mx-auto.float-none.white.z-depth-1.py-2.px-2
|
||||
= bootstrap_form_for(@post) do |f|
|
||||
.card-header
|
||||
- if content_for? :title
|
||||
%h1.h2-responsive.text-center
|
||||
%strong=yield :title
|
||||
- if @post.errors.any?
|
||||
#error_explanation
|
||||
%h2
|
||||
= pluralize(@post.errors.size, "error")
|
||||
prohibited this post from being saved:
|
||||
%ul
|
||||
- @post.errors.full_messages.each do |msg|
|
||||
%li= msg
|
||||
- draft = @post.new_record? || @post.draft?
|
||||
- autosave = { createUrl: posts_path, updateUrl: (post_path(@post.id) if @post.persisted?), subjectId: 'post_subject' }
|
||||
|
||||
.card-body
|
||||
= f.text_field :subject, autofocus: 'autofocus', maxlength: 255
|
||||
= bootstrap_form_for(@post, html: { class: 'post-edit-page' }) do |f|
|
||||
.row
|
||||
.col-12.col-lg-8.mb-3
|
||||
.card.white.z-depth-1
|
||||
.card-header
|
||||
- if content_for? :title
|
||||
%h1.h2-responsive
|
||||
%strong=yield :title
|
||||
- if draft
|
||||
%span.badge.text-bg-warning.me-2 Draft
|
||||
%span.text-muted.small Only you can see this draft until you publish it.
|
||||
- if @post.errors.any?
|
||||
#error_explanation
|
||||
%h2
|
||||
= pluralize(@post.errors.size, "error")
|
||||
prohibited this post from being saved:
|
||||
%ul
|
||||
- @post.errors.full_messages.each do |msg|
|
||||
%li= msg
|
||||
|
||||
- if @post.forum || @forum
|
||||
= label_tag :body, "What's up?"
|
||||
- else
|
||||
= label_tag :body, "What's going on in your food garden?"
|
||||
-# Falls back to the plain textarea if the editor script does not load.
|
||||
= react_component('PostEditor', { name: 'post[body]', value: @post.body.to_s }) do
|
||||
= f.text_area :body, rows: 12, label: false
|
||||
%span.form-text= render partial: "shared/markdown_help"
|
||||
.card-body
|
||||
= f.text_field :subject, autofocus: 'autofocus', maxlength: 255
|
||||
|
||||
- if @post.forum || @forum
|
||||
- forum = @post.forum || @forum
|
||||
%p
|
||||
This post will be posted in the forum
|
||||
= link_to forum.name, forum
|
||||
.field
|
||||
= f.hidden_field :forum_id, value: forum.id
|
||||
.card-footer
|
||||
.text-end= f.submit "Post"
|
||||
- if @post.forum || @forum
|
||||
= label_tag :body, "What's up?"
|
||||
- else
|
||||
= label_tag :body, "What's going on in your food garden?"
|
||||
-# Falls back to the plain textarea if the editor script does not load.
|
||||
- editor_props = { name: 'post[body]', value: @post.body.to_s, previewUrl: preview_posts_path,
|
||||
canAddPhoto: can?(:create, Photo), photos: post_photo_props(@post), cardPhotoId: @post.card_photo_id,
|
||||
postPath: (post_path(@post.id) if @post.persisted?) }
|
||||
- editor_props[:autosave] = autosave if draft
|
||||
= react_component('PostEditor', editor_props) do
|
||||
= f.text_area :body, rows: 12, label: false
|
||||
%span.form-text
|
||||
Use the buttons above to format your text. Type
|
||||
%strong @
|
||||
to link to a crop, a member or one of your plantings.
|
||||
For anything else, the
|
||||
%strong Markdown
|
||||
tab shows the raw text.
|
||||
|
||||
- if @post.forum || @forum
|
||||
- forum = @post.forum || @forum
|
||||
%p
|
||||
This post will be posted in the forum
|
||||
= link_to forum.name, forum
|
||||
.field
|
||||
= f.hidden_field :forum_id, value: forum.id
|
||||
|
||||
- if @post.new_record?
|
||||
.col-lg-4
|
||||
%section.card.mb-3{ 'aria-label': 'Photos' }
|
||||
.card-body
|
||||
%p.text-muted.small.mb-0
|
||||
Use <strong>+ Flickr photo</strong> in the toolbar to put a photo in your post. Its card photo and photos
|
||||
appear here when you next open the draft.
|
||||
- if @post.persisted?
|
||||
.col-lg-4
|
||||
%section.card.mb-3{ 'aria-label': 'Card photo' }
|
||||
.card-header
|
||||
%h2.h5.mb-0 Card photo
|
||||
.card-body
|
||||
- if (card_photo = @post.default_photo)
|
||||
= image_tag card_photo.thumbnail_url, class: 'img-fluid mb-2', alt: card_photo.title
|
||||
%p.text-muted.small Shown on the posts list, your page and shared links.
|
||||
- else
|
||||
%p.text-muted.small Add a photo, then choose which one stands for this post.
|
||||
- if @post.photos.any?
|
||||
= react_component('CardPhotoDialog', card_photo_dialog_props(@post))
|
||||
|
||||
%section.card.mb-3{ 'aria-label': 'Photos in this post' }
|
||||
.card-header.d-flex.justify-content-between
|
||||
%h2.h5.mb-0 Photos
|
||||
- if can?(:create, Photo)
|
||||
= link_to 'pick from flickr', new_photo_path_for(@post), data: photo_dialog_data(@post),
|
||||
class: 'btn btn-sm btn-outline-secondary'
|
||||
.card-body
|
||||
.index-cards
|
||||
- @post.photos.each do |photo|
|
||||
= render 'photos/thumbnail', photo: photo
|
||||
%p.text-muted.small.mb-0 These are photos from your Flickr account.
|
||||
|
||||
.post-save-bar.d-flex.flex-wrap.align-items-center.gap-2
|
||||
%p.text-muted.small.mb-0.me-auto
|
||||
- if draft
|
||||
Saved automatically as you work. Close this page and pick it up later from your posts.
|
||||
- if draft && @post.persisted?
|
||||
= link_to 'Discard draft', post_path(@post), method: :delete, data: { confirm: t('are_you_sure') },
|
||||
class: 'btn btn-outline-danger'
|
||||
- if @post.persisted?
|
||||
= link_to 'Close', post_path(@post), class: 'btn btn-outline-secondary'
|
||||
- if draft
|
||||
= button_tag 'Save draft', name: 'post[draft]', value: 'true', class: 'btn btn-outline-primary'
|
||||
= button_tag 'Publish post', name: 'post[draft]', value: 'false', class: 'btn btn-primary'
|
||||
- else
|
||||
= f.submit 'Save changes', class: 'btn btn-primary'
|
||||
@@ -5,7 +5,9 @@
|
||||
%h4.fw-bold.mb-3
|
||||
%strong
|
||||
= link_to post do
|
||||
= post.subject
|
||||
= post.subject.presence || t('posts.untitled')
|
||||
- if post.draft?
|
||||
%span.badge.text-bg-warning Draft
|
||||
%p
|
||||
by
|
||||
= succeed "," do
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
= link_to @post.forum, @post.forum
|
||||
on
|
||||
= @post.created_at.to_fs(:default)
|
||||
·
|
||||
= t('posts.read_time', count: @post.read_time_minutes)
|
||||
- if @post.updated_at > @post.created_at
|
||||
and edited at
|
||||
= @post.updated_at.to_fs(:default)
|
||||
@@ -17,4 +19,4 @@
|
||||
= link_to "Permalink", @post
|
||||
|
||||
:markdown
|
||||
#{ strip_tags markdownify(@post.body) }
|
||||
#{ markdownify(strip_tags(@post.body), figures: true) }
|
||||
@@ -34,9 +34,19 @@
|
||||
.row.record-page
|
||||
.col-md-8.col-12
|
||||
.card.post{ id: "post-#{@post.id}" }
|
||||
- if (card_photo = @post.default_photo)
|
||||
.post-card-photo
|
||||
= image_tag card_photo.fullsize_url, alt: card_photo.title
|
||||
- if can?(:update, @post) && @post.photos.any?
|
||||
- content_for :scripts do
|
||||
= javascript_include_tag 'react_islands', defer: true
|
||||
.post-card-photo-change
|
||||
= react_component('CardPhotoDialog', card_photo_dialog_props(@post))
|
||||
.card-header
|
||||
%h2.display-3.float-start
|
||||
= @post.subject
|
||||
- if @post.draft?
|
||||
%span.badge.text-bg-warning.fs-6 Draft
|
||||
= render 'posts/actions', post: @post
|
||||
.card-body
|
||||
.post-content= render 'posts/single', post: @post
|
||||
@@ -59,6 +69,17 @@
|
||||
|
||||
.col-md-4.col-12
|
||||
= render @post.author
|
||||
- more = @post.more_from_author
|
||||
- if more.any?
|
||||
%section.more-from-author
|
||||
%h2= t('posts.more_from', author: @post.author)
|
||||
- more.each do |other|
|
||||
= link_to other, class: 'post-compact d-flex gap-2 mb-2' do
|
||||
= image_tag post_thumbnail_path(other), class: 'post-compact-photo', alt: ''
|
||||
%span
|
||||
%strong= other.subject
|
||||
%br
|
||||
%small.text-muted= l(other.created_at.to_date, format: :short)
|
||||
- unless @post.crops.approved.empty?
|
||||
%section.crops
|
||||
%h2 Crops mentioned in this post
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
You can use
|
||||
= link_to 'Markdown', 'https://daringfireball.net/projects/markdown/syntax/'
|
||||
to format your text.
|
||||
You can use Markdown to format your text.
|
||||
%br/
|
||||
For instance, *italic*, **bold**, or a link [like this](http://to.some.site.com/).
|
||||
%br/
|
||||
|
||||
@@ -718,6 +718,7 @@ en:
|
||||
sign_out: Sign out
|
||||
sign_up: Join free
|
||||
nav:
|
||||
my_drafts: My drafts
|
||||
suggest_something: Suggest something
|
||||
links:
|
||||
my_gardens: My gardens
|
||||
@@ -956,10 +957,16 @@ en:
|
||||
posts:
|
||||
created: Post was successfully created.
|
||||
deleted: Post was deleted.
|
||||
draft_saved: Draft was saved.
|
||||
index:
|
||||
title:
|
||||
author_posts: "%{author} posts"
|
||||
default: Everyone's posts
|
||||
more_from: More from %{author}
|
||||
read_time:
|
||||
one: 1 min read
|
||||
other: "%{count} min read"
|
||||
untitled: Untitled post
|
||||
updated: Post was successfully updated.
|
||||
write_blog_post: Write blog post
|
||||
scientific_names:
|
||||
|
||||
@@ -83,6 +83,8 @@ Rails.application.routes.draw do
|
||||
|
||||
resources :posts do
|
||||
get 'author/:author' => 'posts#index', as: 'by_author', on: :collection
|
||||
post :preview, on: :collection
|
||||
get :link_suggestions, on: :collection
|
||||
end
|
||||
|
||||
resources :scientific_names do
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# The photo that stands for a post wherever it shows as a card.
|
||||
class AddCardPhotoToPosts < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_reference :posts, :card_photo, foreign_key: { to_table: :photos, on_delete: :nullify }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,14 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# When a post was published. A post with none is a draft, which only its author can see.
|
||||
class AddPublishedAtToPosts < ActiveRecord::Migration[8.1]
|
||||
def up
|
||||
add_column :posts, :published_at, :datetime
|
||||
execute 'UPDATE posts SET published_at = COALESCE(created_at, NOW())'
|
||||
add_index :posts, :published_at
|
||||
end
|
||||
|
||||
def down
|
||||
remove_column :posts, :published_at
|
||||
end
|
||||
end
|
||||
+6
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.1].define(version: 2026_10_25_000000) do
|
||||
ActiveRecord::Schema[8.1].define(version: 2026_10_27_000100) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pg_catalog.plpgsql"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -1028,15 +1028,19 @@ ActiveRecord::Schema[8.1].define(version: 2026_10_25_000000) do
|
||||
create_table "posts", id: :serial, force: :cascade do |t|
|
||||
t.integer "author_id", null: false
|
||||
t.text "body", null: false
|
||||
t.bigint "card_photo_id"
|
||||
t.integer "comments_count", default: 0
|
||||
t.datetime "created_at", precision: nil
|
||||
t.integer "forum_id"
|
||||
t.integer "likes_count", default: 0
|
||||
t.datetime "published_at"
|
||||
t.string "slug"
|
||||
t.string "subject", null: false
|
||||
t.datetime "updated_at", precision: nil
|
||||
t.index ["card_photo_id"], name: "index_posts_on_card_photo_id"
|
||||
t.index ["created_at", "author_id"], name: "index_posts_on_created_at_and_author_id"
|
||||
t.index ["forum_id"], name: "index_posts_on_forum_id"
|
||||
t.index ["published_at"], name: "index_posts_on_published_at"
|
||||
t.index ["slug"], name: "index_posts_on_slug", unique: true
|
||||
end
|
||||
|
||||
@@ -1126,5 +1130,6 @@ ActiveRecord::Schema[8.1].define(version: 2026_10_25_000000) do
|
||||
add_foreign_key "plantings", "alternate_names", on_delete: :nullify
|
||||
add_foreign_key "plantings", "seeds", column: "parent_seed_id", name: "parent_seed", on_delete: :nullify
|
||||
add_foreign_key "plants", "plantings"
|
||||
add_foreign_key "posts", "photos", column: "card_photo_id", on_delete: :nullify
|
||||
add_foreign_key "seeds", "plantings", column: "parent_planting_id", name: "parent_planting", on_delete: :nullify
|
||||
end
|
||||
@@ -6,6 +6,9 @@ class Haml::Filters
|
||||
CROP_REGEX = /(?<!\\)\[([^\[\]]+?)\]\(crop\)/
|
||||
MEMBER_REGEX = /(?<!\\)\[([^\[\]]+?)\]\(member\)/
|
||||
MEMBER_AT_REGEX = /(?<!\\)(@\w+)/
|
||||
PLANTING_REGEX = /(?<!\\)\[([^\[\]]+?)\]\(planting:([\w-]+)\)/
|
||||
# A Flickr photo placed in a post:  or 
|
||||
PHOTO_REGEX = /^!\[([^\[\]]*)\]\(photo:(\d+)(?:\/(wide|half|pair))?\)[ \t]*$/
|
||||
MEMBER_COMBINED_REGEX = Regexp.union(MEMBER_REGEX, MEMBER_AT_REGEX)
|
||||
MEMBER_ESCAPE_AT_REGEX = /(?<!\\)\\(?=@\w+)/
|
||||
HOST = Rails.application.config.host
|
||||
@@ -21,6 +24,37 @@ class Haml::Filters
|
||||
text
|
||||
end
|
||||
|
||||
def expand_plantings!(text)
|
||||
# turn [my tomato](planting:skud-backyard-tomato) into [my tomato](/plantings/skud-backyard-tomato)
|
||||
text.gsub!(PLANTING_REGEX) do
|
||||
link_text = Regexp.last_match(1)
|
||||
planting = Planting.find_by(slug: Regexp.last_match(2))
|
||||
planting_link planting, link_text
|
||||
end
|
||||
text
|
||||
end
|
||||
|
||||
# With figures, a photo becomes a figure (the post page). Without, it is left
|
||||
# out, as in a teaser, an RSS feed or an email, where a picture is not wanted.
|
||||
def expand_photos!(text, figures: false)
|
||||
text.gsub!(PHOTO_REGEX) do
|
||||
caption = Regexp.last_match(1)
|
||||
photo = Photo.find_by(id: Regexp.last_match(2))
|
||||
figures && photo ? photo_figure(photo, caption, Regexp.last_match(3) || 'wide') : ''
|
||||
end
|
||||
text
|
||||
end
|
||||
|
||||
def photo_figure(photo, caption, size)
|
||||
h = ERB::Util.method(:html_escape)
|
||||
url = Rails.application.routes.url_helpers.photo_path(photo)
|
||||
caption = caption.strip
|
||||
html = +%(\n\n<div class="post-figure post-figure-#{size}">\n)
|
||||
html << %(<a href="#{h.call(url)}"><img src="#{h.call(photo.fullsize_url)}" alt="#{h.call(caption.presence || photo.title)}"></a>\n)
|
||||
html << %(<p class="post-figure-caption">#{h.call(caption)}</p>\n) if caption.present?
|
||||
html << "</div>\n\n"
|
||||
end
|
||||
|
||||
def expand_members!(text)
|
||||
# turn [jane](member) into [jane](http://growstuff.org/members/jane)
|
||||
# turn @jane into [@jane](http://growstuff.org/members/jane)
|
||||
@@ -52,6 +86,15 @@ class Haml::Filters
|
||||
end
|
||||
end
|
||||
|
||||
def planting_link(planting, link_text)
|
||||
if planting
|
||||
url = Rails.application.routes.url_helpers.planting_url(planting, only_path: true)
|
||||
"[#{link_text}](#{url})"
|
||||
else
|
||||
link_text
|
||||
end
|
||||
end
|
||||
|
||||
def find_member(login_name)
|
||||
# Remove @ if present
|
||||
login_name = login_name[1..] if login_name.start_with?('@')
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"@tiptap/pm": "^3.32.0",
|
||||
"@tiptap/react": "^3.32.0",
|
||||
"@tiptap/starter-kit": "^3.32.0",
|
||||
"@tiptap/suggestion": "^3.32.0",
|
||||
"bootstrap": "^5.3.8",
|
||||
"chart.js": "^4.6.1",
|
||||
"chartkick": "^5.0.1",
|
||||
|
||||
@@ -38,7 +38,7 @@ describe "Forums usage", :js do
|
||||
|
||||
fill_in "post_subject", with: "My New Post"
|
||||
find(".post-editor-content").send_keys("Content of my post")
|
||||
click_button "Post"
|
||||
click_button "Publish post"
|
||||
|
||||
expect(page).to have_content("Post was successfully created")
|
||||
expect(Post.last.forum).to eq(forum)
|
||||
|
||||
@@ -10,11 +10,67 @@ describe 'Post a post' do
|
||||
it "creating a post" do
|
||||
fill_in "post_subject", with: "Testing"
|
||||
find(".post-editor-content").send_keys("This is a sample test")
|
||||
click_button "Post"
|
||||
click_button "Publish post"
|
||||
expect(page).to have_content "Post was successfully created"
|
||||
expect(page).to have_content "Posted by"
|
||||
end
|
||||
|
||||
context "linking to a crop" do
|
||||
let!(:crop) { create(:crop, name: 'pumpkin') }
|
||||
|
||||
before do
|
||||
fill_in "post_subject", with: "Pumpkins"
|
||||
find(".post-editor-content").send_keys("I love @pum")
|
||||
end
|
||||
|
||||
it "offers matching crops" do
|
||||
expect(page).to have_css "[role=option]", text: "pumpkin"
|
||||
end
|
||||
|
||||
it "links the chosen crop" do
|
||||
find("[role=option]", text: "pumpkin").click
|
||||
click_button "Publish post"
|
||||
expect(page).to have_link "pumpkin", href: crop_path(crop)
|
||||
end
|
||||
end
|
||||
|
||||
context "adding a Flickr photo" do
|
||||
it "opens the photo dialog for a draft with a title and text" do
|
||||
fill_in "post_subject", with: "Photos"
|
||||
find(".post-editor-content").send_keys("Some words")
|
||||
click_button "Flickr photo"
|
||||
expect(page).to have_css "[role=dialog]", text: "Add photo"
|
||||
end
|
||||
|
||||
it "opens the photo dialog even before anything is written" do
|
||||
click_button "Flickr photo"
|
||||
expect(page).to have_css "[role=dialog]", text: "Add photo"
|
||||
end
|
||||
end
|
||||
|
||||
context "saving a draft" do
|
||||
before do
|
||||
fill_in "post_subject", with: "Half written"
|
||||
find(".post-editor-content").send_keys("Not finished")
|
||||
click_button "Save draft"
|
||||
end
|
||||
|
||||
it "keeps it as a draft" do
|
||||
expect(page).to have_content "Draft was saved"
|
||||
end
|
||||
|
||||
it "labels it a draft" do
|
||||
expect(page).to have_css ".badge", text: "Draft"
|
||||
end
|
||||
|
||||
it "publishes it from the editor" do
|
||||
click_link "Actions"
|
||||
click_link "Edit"
|
||||
click_button "Publish post"
|
||||
expect(page).to have_no_css ".badge", text: "Draft"
|
||||
end
|
||||
end
|
||||
|
||||
context "editing a post" do
|
||||
let(:existing_post) { create(:post, author: member) }
|
||||
|
||||
@@ -31,7 +87,7 @@ describe 'Post a post' do
|
||||
|
||||
it "saving edit" do
|
||||
fill_in "post_subject", with: "Testing Edit"
|
||||
click_button "Post"
|
||||
click_button "Save changes"
|
||||
expect(page).to have_content "Post was successfully updated"
|
||||
expect(page).to have_content "edited at"
|
||||
end
|
||||
|
||||
@@ -52,6 +52,69 @@ describe 'Haml::Filters::Growstuff_Markdown' do
|
||||
expect(rendered).to match(/#{output_link(tomato)} #{output_link(maize)}/)
|
||||
end
|
||||
|
||||
describe 'planting links' do
|
||||
subject { markdownify(text) }
|
||||
|
||||
let(:planting) { create(:planting) }
|
||||
let(:url) { Rails.application.routes.url_helpers.planting_url(planting, only_path: true) }
|
||||
|
||||
context 'with a planting that exists' do
|
||||
let(:text) { "See [my tomato](planting:#{planting.slug})" }
|
||||
|
||||
it { is_expected.to eq "See [my tomato](#{url})" }
|
||||
end
|
||||
|
||||
context 'with a planting that does not exist' do
|
||||
let(:text) { 'See [my tomato](planting:nowhere)' }
|
||||
|
||||
it { is_expected.to eq 'See my tomato' }
|
||||
end
|
||||
|
||||
context 'when escaped' do
|
||||
let(:text) { "\\[my tomato](planting:#{planting.slug})" }
|
||||
|
||||
it { is_expected.to eq text }
|
||||
end
|
||||
end
|
||||
|
||||
describe 'photos' do
|
||||
let(:photo) { create(:photo) }
|
||||
let(:text) { "Before\n\n\n\nAfter" }
|
||||
|
||||
context 'with figures' do
|
||||
subject { markdownify(text, figures: true) }
|
||||
|
||||
it { is_expected.to include %(<div class="post-figure post-figure-half">) }
|
||||
it { is_expected.to include %(<img src="#{photo.fullsize_url}" alt="A jar">) }
|
||||
it { is_expected.to include '<p class="post-figure-caption">A jar</p>' }
|
||||
it { is_expected.to include %(<a href="#{Rails.application.routes.url_helpers.photo_path(photo)}">) }
|
||||
end
|
||||
|
||||
context 'with a caption containing html' do
|
||||
subject { markdownify(text.sub('A jar', 'a "b" & c'), figures: true) }
|
||||
|
||||
it { is_expected.to include 'alt="a "b" & c"' }
|
||||
end
|
||||
|
||||
context 'without figures' do
|
||||
subject { markdownify(text) }
|
||||
|
||||
it { is_expected.to eq "Before\n\n\n\nAfter" }
|
||||
end
|
||||
|
||||
context 'when the photo does not exist' do
|
||||
subject { markdownify("", figures: true) }
|
||||
|
||||
it { is_expected.to eq '' }
|
||||
end
|
||||
|
||||
context 'with no size' do
|
||||
subject { markdownify("", figures: true) }
|
||||
|
||||
it { is_expected.to include 'post-figure-wide' }
|
||||
end
|
||||
end
|
||||
|
||||
it "converts normal markdown" do
|
||||
string = "**foo**"
|
||||
rendered = render_haml(haml_template(string))
|
||||
|
||||
@@ -179,4 +179,153 @@ describe Post do
|
||||
member.destroy
|
||||
expect(described_class.joins(:author).all).not_to include(post)
|
||||
end
|
||||
|
||||
describe '#default_photo' do
|
||||
subject { post.default_photo }
|
||||
|
||||
let(:post) { create(:post, author: member) }
|
||||
let(:liked) { create(:photo, owner: member, likes_count: 5) }
|
||||
let(:chosen) { create(:photo, owner: member) }
|
||||
|
||||
before do
|
||||
post.photos << liked
|
||||
post.photos << chosen
|
||||
end
|
||||
|
||||
context 'without a card photo' do
|
||||
it { is_expected.to eq liked }
|
||||
end
|
||||
|
||||
context 'with a card photo' do
|
||||
before { post.update!(card_photo: chosen) }
|
||||
|
||||
it { is_expected.to eq chosen }
|
||||
end
|
||||
end
|
||||
|
||||
describe 'card photo validation' do
|
||||
subject(:post) { build(:post, author: member, card_photo: photo) }
|
||||
|
||||
context 'when the photo is not in the post' do
|
||||
let(:photo) { create(:photo, owner: member) }
|
||||
|
||||
it { is_expected.not_to be_valid }
|
||||
end
|
||||
end
|
||||
|
||||
describe '#read_time_minutes' do
|
||||
subject { build(:post, body: ('word ' * words)).read_time_minutes }
|
||||
|
||||
context 'with a short body' do
|
||||
let(:words) { 10 }
|
||||
|
||||
it { is_expected.to eq 1 }
|
||||
end
|
||||
|
||||
context 'with a long body' do
|
||||
let(:words) { 450 }
|
||||
|
||||
it { is_expected.to eq 3 }
|
||||
end
|
||||
end
|
||||
|
||||
describe '#more_from_author' do
|
||||
subject { post.more_from_author }
|
||||
|
||||
let(:post) { create(:post, author: member) }
|
||||
let!(:other) { create(:post, author: member) }
|
||||
|
||||
before { create(:post) }
|
||||
|
||||
it { is_expected.to eq [other] }
|
||||
end
|
||||
|
||||
describe 'drafts' do
|
||||
let(:draft) { create(:post, author: member, draft: true) }
|
||||
|
||||
it 'has no published date' do
|
||||
expect(draft.published_at).to be_nil
|
||||
end
|
||||
|
||||
it 'is a draft' do
|
||||
expect(draft).to be_draft
|
||||
end
|
||||
|
||||
it 'is left out of the default scope' do
|
||||
draft
|
||||
expect(described_class.all).not_to include draft
|
||||
end
|
||||
|
||||
it 'is found with drafts' do
|
||||
expect(described_class.with_drafts).to include draft
|
||||
end
|
||||
|
||||
context 'when published' do
|
||||
before { draft.update!(draft: false) }
|
||||
|
||||
it { expect(draft).to be_published }
|
||||
end
|
||||
|
||||
context 'when saved again as a draft' do
|
||||
before { draft.update!(subject: 'New subject', draft: true) }
|
||||
|
||||
it { expect(draft).to be_draft }
|
||||
end
|
||||
|
||||
context 'when saved with no draft setting' do
|
||||
before { draft.update!(subject: 'New subject') }
|
||||
|
||||
it { expect(draft).to be_draft }
|
||||
end
|
||||
|
||||
context 'when saved blank' do
|
||||
subject(:blank) { build(:post, author: member, subject: '', body: '', draft: true) }
|
||||
|
||||
it { is_expected.to be_valid }
|
||||
end
|
||||
|
||||
context 'when published blank' do
|
||||
subject(:blank) { build(:post, author: member, subject: '', body: '', draft: false) }
|
||||
|
||||
it { is_expected.not_to be_valid }
|
||||
end
|
||||
|
||||
context 'when a blank draft is published' do
|
||||
before { draft.update(subject: '', body: '', draft: true) }
|
||||
|
||||
it 'is rejected' do
|
||||
expect(draft.update(draft: false)).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when its title changes' do
|
||||
before { draft.update!(subject: 'A better title', draft: true) }
|
||||
|
||||
it 'follows the title in its address' do
|
||||
expect(draft.slug).to end_with 'a-better-title'
|
||||
end
|
||||
end
|
||||
|
||||
context 'when published and its title changes' do
|
||||
let(:post) { create(:post, author: member) }
|
||||
|
||||
before { post.update!(subject: 'Something else') }
|
||||
|
||||
it 'keeps its address' do
|
||||
expect { post.reload }.not_to change(post, :slug)
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not notify mentioned members' do
|
||||
mentioned = create(:member)
|
||||
create(:post, author: member, draft: true, body: "hello @#{mentioned.login_name}")
|
||||
expect(Notification.where(recipient: mentioned)).to be_empty
|
||||
end
|
||||
|
||||
it 'notifies mentioned members once it is published' do
|
||||
mentioned = create(:member)
|
||||
create(:post, author: member, draft: true, body: "hello @#{mentioned.login_name}").update!(draft: false)
|
||||
expect(Notification.where(recipient: mentioned).count).to eq 1
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -68,12 +68,19 @@ describe 'Adding a photo as JSON' do
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns what the editor needs to draw the photo' do
|
||||
add_photo
|
||||
expect(response.parsed_body['photo']).to include(
|
||||
'title' => 'Cabbage', 'thumbnail_url' => 'http://example.com/t.jpg', 'fullsize_url' => 'http://example.com/f.jpg'
|
||||
)
|
||||
end
|
||||
|
||||
it "puts the photo on the planting and returns the garden's refreshed card" do
|
||||
expect { add_photo }.to change { planting.photos.count }.by(1)
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
photo = planting.photos.last
|
||||
expect(response.parsed_body['photo']).to eq('id' => photo.id, 'url' => "/photos/#{photo.id}")
|
||||
expect(response.parsed_body['photo']).to include('id' => photo.id, 'url' => "/photos/#{photo.id}")
|
||||
expect(response.parsed_body.dig('garden', 'id')).to eq garden.id
|
||||
expect(photo).to have_attributes(owner: member, source: 'flickr', source_id: '1234', title: 'Cabbage')
|
||||
end
|
||||
|
||||
@@ -9,4 +9,152 @@ describe "Posts" do
|
||||
response.status.should be(200)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PATCH /posts/:id with a card photo' do
|
||||
subject(:request) { patch post_path(post, format: :json), params: { post: { card_photo_id: photo.id } } }
|
||||
|
||||
let(:member) { create(:member) }
|
||||
let(:post) { create(:post, author: member) }
|
||||
let(:photo) { create(:photo, owner: member) }
|
||||
|
||||
before do
|
||||
post.photos << photo
|
||||
sign_in member
|
||||
end
|
||||
|
||||
it 'sets the card photo' do
|
||||
request
|
||||
expect(post.reload.card_photo).to eq photo
|
||||
end
|
||||
|
||||
context 'when the photo is not in the post' do
|
||||
let(:photo) { create(:photo, owner: member) }
|
||||
|
||||
before { post.photos.destroy_all }
|
||||
|
||||
it 'is rejected' do
|
||||
request
|
||||
expect(response).to have_http_status :unprocessable_entity
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'drafts' do
|
||||
let(:member) { create(:member) }
|
||||
let!(:draft) { create(:post, author: member, draft: true) }
|
||||
|
||||
context 'when signed in as the author' do
|
||||
before { sign_in member }
|
||||
|
||||
it 'shows the draft' do
|
||||
get post_path(draft)
|
||||
expect(response).to have_http_status :ok
|
||||
end
|
||||
|
||||
it 'lists the draft on their own page' do
|
||||
get member_posts_path(member_slug: member.slug)
|
||||
expect(response.body).to include draft.subject
|
||||
end
|
||||
|
||||
it 'lists the draft under their drafts' do
|
||||
get member_posts_path(member_slug: member.slug, drafts: 1)
|
||||
expect(response.body).to include draft.subject
|
||||
end
|
||||
|
||||
it 'leaves published posts out of their drafts' do
|
||||
published = create(:post, author: member)
|
||||
get member_posts_path(member_slug: member.slug, drafts: 1)
|
||||
expect(response.body).not_to include published.subject
|
||||
end
|
||||
|
||||
it 'links to their drafts from the sidebar' do
|
||||
get member_posts_path(member_slug: member.slug)
|
||||
expect(response.body).to include 'My drafts'
|
||||
end
|
||||
|
||||
it 'publishes the draft' do
|
||||
patch post_path(draft), params: { post: { draft: 'false' } }
|
||||
expect(draft.reload).to be_published
|
||||
end
|
||||
|
||||
it 'keeps the draft a draft when saved as one' do
|
||||
patch post_path(draft), params: { post: { subject: 'Still drafting', draft: 'true' } }
|
||||
expect(draft.reload).to be_draft
|
||||
end
|
||||
|
||||
it 'creates a blank draft for a photo to go on' do
|
||||
post posts_path(format: :json), params: { post: { subject: '', body: '', draft: true } }
|
||||
expect(response).to have_http_status :created
|
||||
end
|
||||
|
||||
it 'creates a draft from an autosave' do
|
||||
post posts_path(format: :json), params: { post: { subject: 'Auto', body: 'saved', draft: true } }
|
||||
expect(Post.with_drafts.find_by(subject: 'Auto')).to be_draft
|
||||
end
|
||||
end
|
||||
|
||||
context 'when signed in as someone else' do
|
||||
before { sign_in create(:member) }
|
||||
|
||||
it 'does not show the draft' do
|
||||
get post_path(draft)
|
||||
expect(response).to have_http_status :not_found
|
||||
end
|
||||
|
||||
it 'does not list the draft on the author page' do
|
||||
get member_posts_path(member_slug: member.slug)
|
||||
expect(response.body).not_to include draft.subject
|
||||
end
|
||||
end
|
||||
|
||||
context 'when signed out' do
|
||||
it 'does not list the draft' do
|
||||
get posts_path
|
||||
expect(response.body).not_to include draft.subject
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /posts/preview' do
|
||||
before { sign_in create(:member) }
|
||||
|
||||
it 'renders the markdown' do
|
||||
post preview_posts_path, params: { body: '**bold**' }, as: :json
|
||||
expect(response.body).to include '<strong>bold</strong>'
|
||||
end
|
||||
|
||||
it 'strips scripts' do
|
||||
post preview_posts_path, params: { body: '<script>alert(1)</script>' }, as: :json
|
||||
expect(response.body).not_to include '<script'
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /posts/link_suggestions' do
|
||||
subject(:json) { response.parsed_body }
|
||||
|
||||
let(:member) { create(:member) }
|
||||
let!(:friend) { create(:member, login_name: 'pumpkinfan') }
|
||||
let(:crop) { create(:crop, name: 'pumpkin') }
|
||||
let!(:mine) { create(:planting, owner: member, crop:) }
|
||||
|
||||
before do
|
||||
create(:planting, crop:) # someone else's
|
||||
sign_in member
|
||||
get link_suggestions_posts_path(format: :json), params: { term: 'pump' }
|
||||
end
|
||||
|
||||
it 'finds members by login name' do
|
||||
expect(json['members']).to eq [{ 'name' => 'pumpkinfan' }]
|
||||
end
|
||||
|
||||
it 'finds only the writers own plantings' do
|
||||
expect(json['plantings'].pluck('slug')).to eq [mine.slug]
|
||||
end
|
||||
|
||||
context 'without a term' do
|
||||
before { get link_suggestions_posts_path(format: :json) }
|
||||
|
||||
it { expect(json).to eq('members' => [], 'plantings' => []) }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -18,6 +18,14 @@ describe "posts/_single markdown body" do
|
||||
it { is_expected.to include "<strong>strong</strong>" }
|
||||
end
|
||||
|
||||
context "with a photo placed in the text" do
|
||||
let(:photo) { create(:photo) }
|
||||
let(:body) { "Look\n\n\n\nNice" }
|
||||
|
||||
it { is_expected.to include %(<img src="#{photo.fullsize_url}" alt="A jar") }
|
||||
it { is_expected.to include 'post-figure-caption' }
|
||||
end
|
||||
|
||||
context "with a crop link" do
|
||||
let(:crop) { create(:tomato) }
|
||||
let(:body) { "Growing [#{crop.name}](crop)" }
|
||||
|
||||
@@ -128,4 +128,56 @@ describe "posts/show" do
|
||||
expect(subject).to have_css "#new-comment form[action='#{comments_path}']"
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a card photo' do
|
||||
let(:post) { create(:post, author:) }
|
||||
let(:photo) { create(:photo, owner: author) }
|
||||
|
||||
before do
|
||||
post.photos << photo
|
||||
post.update!(card_photo: photo)
|
||||
render
|
||||
end
|
||||
|
||||
it 'leads the post with the photo' do
|
||||
expect(subject).to have_css ".post-card-photo img[src='#{photo.fullsize_url}']"
|
||||
end
|
||||
end
|
||||
|
||||
context 'without any photo' do
|
||||
let(:post) { create(:post, author:) }
|
||||
|
||||
before { render }
|
||||
|
||||
it { is_expected.to have_no_css '.post-card-photo' }
|
||||
end
|
||||
|
||||
context 'when the author has other posts' do
|
||||
let(:post) { create(:post, author:) }
|
||||
|
||||
before do
|
||||
create(:post, author:, subject: 'Spring sowing')
|
||||
render
|
||||
end
|
||||
|
||||
it 'lists them in the sidebar' do
|
||||
expect(subject).to have_css '.more-from-author', text: 'Spring sowing'
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the author has no other posts' do
|
||||
let(:post) { create(:post, author:) }
|
||||
|
||||
before { render }
|
||||
|
||||
it { is_expected.to have_no_css '.more-from-author' }
|
||||
end
|
||||
|
||||
context 'with a short body' do
|
||||
let(:post) { create(:post, author:, body: 'hello there') }
|
||||
|
||||
before { render }
|
||||
|
||||
it { is_expected.to have_text '1 min read' }
|
||||
end
|
||||
end
|
||||
@@ -1163,6 +1163,11 @@
|
||||
"@tiptap/extensions" "3.32.0"
|
||||
"@tiptap/pm" "3.32.0"
|
||||
|
||||
"@tiptap/suggestion@^3.32.0":
|
||||
version "3.32.0"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/suggestion/-/suggestion-3.32.0.tgz#52fdfda24bd71ca36f0b587cd4eb8c57d86fee3a"
|
||||
integrity sha512-5PbouU06zaZ7n4uyYzeNN/jBFVmGsHZ6c56MvN7M33echERAhu1L1X71Up0U6GK/pJfyugX7WbluBehJDPQeSQ==
|
||||
|
||||
"@tootallnate/quickjs-emscripten@^0.23.0":
|
||||
version "0.23.0"
|
||||
resolved "https://registry.yarnpkg.com/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz#db4ecfd499a9765ab24002c3b696d02e6d32a12c"
|
||||
|
||||
Reference in new issue
Block a user