Merge branch 'main' into api/graphiti

This commit is contained in:
Brenda Wallace authored and GitHub committed 2026-10-28 21:54:10 +13:00
commit a026a00a60
39 files changed
+2244 -66

No files matched your search

+297
View File
@@ -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;
}
}
+7 -2
View File
@@ -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
#
+57 -6
View File
@@ -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)
+7 -1
View File
@@ -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
+20
View File
@@ -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();
});
});
+153 -6
View File
@@ -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}
+247 -2
View File
@@ -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![Day two](photo:5/half)\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('![Fresh](photo:5/half)'));
});
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: '![x](photo:99)'});
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));
});
});
});
+150
View File
@@ -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;
};
}
+149
View File
@@ -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" />,
]],
];
// ![caption](photo:123) or ![caption](photo:123/half), 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 `![${clean(caption)}](photo:${photoId}/${size})`;
},
});
@@ -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';
}
+2 -1
View File
@@ -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
View File
@@ -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)
+5 -1
View File
@@ -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)
+3
View File
@@ -0,0 +1,3 @@
.post-content
:markdown
#{ markdownify(strip_tags(body), figures: true) }
+97 -33
View File
@@ -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'
+3 -1
View File
@@ -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
+3 -1
View File
@@ -10,6 +10,8 @@
= link_to @post.forum, @post.forum
on
= @post.created_at.to_fs(:default)
&middot;
= 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) }
+21
View File
@@ -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 -3
View File
@@ -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/
+7
View File
@@ -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:
+2
View File
@@ -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
View File
@@ -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
+43
View File
@@ -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: ![caption](photo:123) or ![caption](photo:123/half)
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?('@')
+1
View File
@@ -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",
+1 -1
View File
@@ -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)
+58 -2
View File
@@ -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![A jar](photo:#{photo.id}/half)\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 &quot;b&quot; &amp; 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("![x](photo:0)", figures: true) }
it { is_expected.to eq '' }
end
context 'with no size' do
subject { markdownify("![](photo:#{photo.id})", 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))
+149
View File
@@ -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
+8 -1
View File
@@ -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
+148
View File
@@ -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![A jar](photo:#{photo.id}/wide)\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)" }
+52
View File
@@ -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
+5
View File
@@ -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"