Move new listing form to Preact (#2674)

* Fix typo

* Use preact for new listing form

* Send proper max tag amount in error msg

* Add styles back in for edit page
This commit is contained in:
Andy Zhao 2019-05-03 10:17:12 -04:00 committed by Ben Halpern
parent d8c38bd855
commit 07796e769a
12 changed files with 589 additions and 31 deletions

View file

@ -186,7 +186,7 @@
* {
box-sizing: border-box;
}
.listings-back-buton {
.listings-back-button {
font-size: 0.8em;
margin: 5px 0px 10px;
display: block;
@ -263,4 +263,52 @@
font-size: 0.8em;
}
}
}
}
.field {
font-size: 18px;
}
.listingform__input {
margin-top: 10px;
}
.listingform__bodymarkdown {
width: 100%;
height: 300px;
padding: 5px;
font-size: 17px;
border-radius: 3px;
background: white;
background: var(--theme-container-background, white);
color: $black;
color: var(--theme-color, $black);
border: 1px solid $light-medium-gray;
}
.listingform__tagoptionrow {
padding: 10px;
cursor: pointer;
&:hover {
background: lighten($green, 27%);
color: $black;
}
}
.listingform__tagoptionrow--active {
background: $green;
color: $black;
&:hover {
background: darken($green, 10%);
}
}
.listingform__label {
width: 100%;
display: inline-block;
font-size: 0.7em;
margin: 8px 0px 3px 0px;
border-radius: 3px;
padding: 6px 0px;
font-weight: bold;
}

View file

@ -0,0 +1 @@
@import 'variables';

View file

@ -0,0 +1,26 @@
import { h } from 'preact';
import PropTypes from 'prop-types';
const BodyMarkdown = ({ onChange, defaultValue }) => (
<div className="field">
<label className="listingform__label" htmlFor="body_markdown">
Body Markdown
<textarea
className="listingform__input listingform__bodymarkdown"
id="body_markdown"
name="classified_listing[body_markdown]"
maxLength="400"
placeholder="400 characters max, 12 line break max, no images allowed"
value={defaultValue}
onInput={onChange}
/>
</label>
</div>
)
BodyMarkdown.propTypes = {
onChange: PropTypes.func.isRequired,
defaultValue: PropTypes.string.isRequired,
}
export default BodyMarkdown;

View file

@ -0,0 +1,56 @@
import PropTypes from 'prop-types';
import { h, Component } from 'preact';
class Categories extends Component {
options = () => {
const { categoriesForSelect } = this.props
return categoriesForSelect.map(array => {
return(
<option value={array[1]}>{array[0]}</option>
)
})
}
details = () => {
const { categoriesForDetails } = this.props
const rules = categoriesForDetails.map(category => {
const paragraphText = `${category.name}: ${category.rules}`
return(
<p>
{paragraphText}
</p>
)
})
return(
<details>
<summary>
Category details/rules
</summary>
{rules}
</details>
)
}
render() {
return(
<div className="field">
<label className="listingform__label" htmlFor="category">
Category
</label>
<select className="listingform__input" name="classified_listing[category]">
{this.options()}
</select>
{this.details()}
</div>
)
}
}
Categories.propTypes = {
categoriesForSelect: PropTypes.array.isRequired,
categoriesForDetails: PropTypes.array.isRequired,
}
export default Categories;

View file

@ -0,0 +1,335 @@
import { h, Component } from 'preact';
import PropTypes from 'prop-types';
const KEYS = {
UP: 38,
DOWN: 40,
LEFT: 37,
RIGHT: 39,
TAB: 9,
RETURN: 13,
COMMA: 188,
DELETE: 8,
};
const MAX_TAGS = 8;
class Tags extends Component {
constructor(props) {
super(props);
this.state = {
selectedIndex: -1,
searchResults: [],
cursorIdx: 0,
prevLen: 0,
};
const algoliaId = document.querySelector("meta[name='algolia-public-id']")
.content;
const algoliaKey = document.querySelector("meta[name='algolia-public-key']")
.content;
const env = document.querySelector("meta[name='environment']").content;
const client = algoliasearch(algoliaId, algoliaKey);
this.index = client.initIndex(`Tag_${env}`);
}
get selected() {
return this.props.defaultValue
.split(',')
.map(item => item !== undefined && item.trim())
.filter(item => item.length > 0);
}
componentDidUpdate() {
// stop cursor jumping if the user goes back to edit previous tags
if (
this.state.cursorIdx < this.textArea.value.length &&
this.textArea.value.length < this.state.prevLen + 1
) {
this.textArea.selectionStart = this.textArea.selectionEnd = this.state.cursorIdx;
}
}
render() {
let searchResultsHTML = '';
const searchResultsRows = this.state.searchResults.map((tag, index) => (
<div
tabIndex="-1"
className={`listingform__tagoptionrow listingform__tagoptionrow--${
this.state.selectedIndex === index ? 'active' : 'inactive'
}`}
onClick={this.handleTagClick}
data-content={tag.name}
>
{tag.name}
</div>
));
if (
this.state.searchResults.length > 0 &&
document.activeElement.id === 'tag-input'
) {
searchResultsHTML = (
<div className="listingform__tagsoptions">{searchResultsRows}</div>
);
}
return (
<div className="listingform__tagswrapper">
<label htmlFor="Tags">Tags</label>
<input
id="tag-input"
name="classified_listing[tag_list]"
type="text"
ref={t => (this.textArea = t)}
className="listingform__input listingform__tagsinput"
placeholder="8 tags max, comma separated, no spaces or special characters"
value={this.props.defaultValue}
onInput={this.handleInput}
onKeyDown={this.handleKeyDown}
onBlur={this.handleFocusChange}
onFocus={this.handleFocusChange}
/>
{searchResultsHTML}
</div>
);
}
handleTagClick = e => {
const input = document.getElementById('tag-input');
input.focus();
this.insertTag(e.target.dataset.content);
};
handleInput = e => {
let { value } = e.target;
// If we start typing immediately after a comma, add a space
// before what we typed.
// e.g. If value = "javascript," and we type a "p",
// the result should be "javascript, p".
if (e.inputType === 'insertText') {
const isTypingAfterComma =
e.target.value[e.target.selectionStart - 2] === ',';
if (isTypingAfterComma) {
value = this.insertSpace(value, e.target.selectionStart - 1);
}
}
if (e.data === ',') {
value += ' ';
}
this.props.onInput(value);
const query = this.getCurrentTagAtSelectionIndex(
e.target.value,
e.target.selectionStart - 1,
);
this.setState({
selectedIndex: 0,
cursorIdx: e.target.selectionStart,
prevLen: this.textArea.value.length,
});
return this.search(query);
};
insertSpace(value, position) {
return `${value.slice(0, position)} ${value.slice(position, value.length)}`;
}
getCurrentTagAtSelectionIndex(value, index) {
let tagIndex = 0;
const tagByCharacterIndex = {};
value.split('').map((letter, index) => {
if (letter === ',') {
tagIndex++;
} else {
tagByCharacterIndex[index] = tagIndex;
}
});
const tag = value.split(',')[tagByCharacterIndex[index]];
if (tag === undefined) {
return '';
}
return tag.trim();
}
search(query) {
if (query === '') {
return new Promise(resolve => {
setTimeout(() => {
this.resetSearchResults();
resolve();
}, 5);
});
}
return this.index
.search(query, {
hitsPerPage: 10,
attributesToHighlight: [],
filters: 'supported:true',
})
.then(content => {
this.setState({
searchResults: content.hits.filter(
hit => !this.selected.includes(hit.name),
),
});
});
}
resetSearchResults() {
this.setState({
searchResults: [],
});
}
handleKeyDown = e => {
const component = this;
if (component.selected.length === MAX_TAGS && e.keyCode === KEYS.COMMA) {
e.preventDefault();
return;
}
if (
(e.keyCode === KEYS.DOWN || e.keyCode === KEYS.TAB) &&
!this.isBottomOfSearchResults &&
component.props.defaultValue != ''
) {
e.preventDefault();
this.moveDownInSearchResults();
} else if (e.keyCode === KEYS.UP && !this.isTopOfSearchResults) {
e.preventDefault();
this.moveUpInSearchResults();
} else if (e.keyCode === KEYS.RETURN && this.isSearchResultSelected) {
e.preventDefault();
this.insertTag(
component.state.searchResults[component.state.selectedIndex].name,
);
setTimeout(() => {
document.getElementById('tag-input').focus();
}, 10);
} else if (e.keyCode === KEYS.COMMA && !this.isSearchResultSelected) {
this.resetSearchResults();
this.clearSelectedSearchResult();
} else if (e.keyCode === KEYS.DELETE) {
if (
component.props.defaultValue[
component.props.defaultValue.length - 1
] === ','
) {
this.clearSelectedSearchResult();
}
} else if (
(e.keyCode < 65 || e.keyCode > 90) &&
e.keyCode != KEYS.COMMA &&
e.keyCode != KEYS.DELETE &&
e.keyCode != KEYS.LEFT &&
e.keyCode != KEYS.RIGHT &&
e.keyCode != KEYS.TAB
) {
e.preventDefault();
}
};
moveUpInSearchResults() {
this.setState({
selectedIndex: this.state.selectedIndex - 1,
});
}
moveDownInSearchResults() {
this.setState({
selectedIndex: this.state.selectedIndex + 1,
});
}
get isTopOfSearchResults() {
return this.state.selectedIndex <= 0;
}
get isBottomOfSearchResults() {
return this.state.selectedIndex >= this.state.searchResults.length - 1;
}
get isSearchResultSelected() {
return this.state.selectedIndex > -1;
}
clearSelectedSearchResult() {
this.setState({
selectedIndex: -1,
});
}
insertTag(tag) {
const input = document.getElementById('tag-input');
const range = this.getRangeBetweenCommas(input.value, input.selectionStart);
const insertingAtEnd = range[1] === input.value.length;
const maxTagsWillBeReached = this.selected.length === MAX_TAGS;
if (insertingAtEnd && !maxTagsWillBeReached) {
tag += ', ';
}
// Insert new tag between commas if there are any.
const newInput =
input.value.slice(0, range[0]) +
tag +
input.value.slice(range[1], input.value.length);
this.props.onInput(newInput);
this.resetSearchResults();
this.clearSelectedSearchResult();
}
// Given an index of the String value, finds the range between commas.
// This is useful when we want to insert a new tag anywhere in the
// comma separated list of tags.
getRangeBetweenCommas(value, index) {
let start = 0;
let end = value.length;
const toPreviousComma = value
.slice(0, index)
.split('')
.reverse()
.indexOf(',');
const toNextComma = value.slice(index).indexOf(',');
if (toPreviousComma !== -1) {
start = index - toPreviousComma + 1;
}
if (toNextComma !== -1) {
end = index + toNextComma;
}
return [start, end];
}
handleFocusChange = e => {
const component = this;
setTimeout(() => {
if (document.activeElement.id === 'tag-input') {
return;
}
component.forceUpdate();
}, 100);
};
}
Tags.propTypes = {
defaultValue: PropTypes.string.isRequired,
};
export default Tags;

View file

@ -0,0 +1,32 @@
import PropTypes from 'prop-types';
import { h } from 'preact';
const domId = 1;
const Title = ( { onChange, defaultValue }) => (
<div className="field">
<label className="listingform__label" htmlFor={domId}>
Title
<input
type="text"
className="listingform__input"
id={domId}
name="classified_listing[title]"
maxLength="128"
size="128"
placeholder="128 characters max, plain text"
autoComplete="off"
value={defaultValue}
onInput={onChange}
/>
</label>
</div>
)
Title.propTypes = {
onChange: PropTypes.func.isRequired,
defaultValue: PropTypes.string.isRequired,
}
export default Title;

View file

@ -0,0 +1,63 @@
import { h, Component } from 'preact';
import linkState from 'linkstate';
import Title from './elements/title';
import BodyMarkdown from './elements/bodyMarkdown';
import Categories from './elements/categories';
import Tags from './elements/tags';
export default class ListingForm extends Component {
constructor(props) {
super(props);
this.listing = JSON.parse(this.props.listing);
this.categoriesForDetails = JSON.parse(this.props.categoriesForDetails);
this.categoriesForSelect = JSON.parse(this.props.categoriesForSelect);
const organizations = this.props.organizations
? JSON.parse(this.props.organizations) : null;
this.url = window.location.href;
this.state = {
id: this.listing.id || null,
title: this.listing.title || '',
tagList: this.listing.cached_tag_list || '',
bodyMarkdown: this.listing.body_markdown || '',
organizations: organizations || null,
categoriesForSelect: this.categoriesForSelect,
categoriesForDetails: this.categoriesForDetails,
}
}
render() {
const {
id,
title,
bodyMarkdown,
tagList,
organizations,
categoriesForDetails,
categoriesForSelect,
} = this.state;
if (id === null) {
return(
<div>
<Title defaultValue={title} onChange={linkState(this, 'title')} />
<BodyMarkdown defaultValue={bodyMarkdown} onChange={linkState(this, 'bodyMarkdown')} />
<Categories categoriesForSelect={categoriesForSelect} categoriesForDetails={categoriesForDetails} />
<Tags defaultValue={tagList} onInput={linkState(this, 'tagList')} />
{/* add contact via connect checkbox later */}
</div>
)
}
return(
<div>
<Title defaultValue={title} onChange={linkState(this, 'title')} />
<BodyMarkdown defaultValue={bodyMarkdown} onChange={linkState(this, 'bodyMarkdown')} />
<Tags defaultValue={tagList} onInput={linkState(this, 'tagList')} />
{/* add contact via connect checkbox later */}
</div>
)
}
}

View file

@ -0,0 +1,15 @@
import { h, render } from 'preact';
import ListingForm from '../listings/listingForm';
const root = document.getElementById('listingform-data');
const { listing, organizations, categoriesForSelect, categoriesForDetails } = root.dataset;
render(
<ListingForm
organizations={organizations}
listing={listing}
categoriesForSelect={categoriesForSelect}
categoriesForDetails={categoriesForDetails}
/>,
root,
);

View file

@ -66,7 +66,7 @@ class ClassifiedListing < ApplicationRecord
"collabs" => { cost: 1, name: "Contributors/Collaborators Wanted" },
"education" => { cost: 1, name: "Education/Courses", rules: "Educational material and/or schools/bootcamps" },
"jobs" => { cost: 10, name: "Job Listings", rules: "Companies offering employment right now." },
"products" => { cost: 1, name: "Products/Tools", rules: "Must be availabel right now" },
"products" => { cost: 1, name: "Products/Tools", rules: "Must be available right now" },
"events" => { cost: 1, name: "Upcoming Events", rules: "Live or online events with date included" },
"misc" => { cost: 1, name: "Miscellaneous", rules: "Must not fit in any other category." }
}
@ -91,7 +91,7 @@ class ClassifiedListing < ApplicationRecord
end
def validate_tags
errors.add(:tag_list, "exceed the maximum of 4 tags") if tag_list.length > 8
errors.add(:tag_list, "exceed the maximum of 8 tags") if tag_list.length > 8
end
def validate_category

View file

@ -1,5 +1,5 @@
<%= form_with(model: classified_listing, local: true) do |form| %>
<a href="/listings" class="listings-back-buton">< go back to listings</a>
<a href="/listings" class="listings-back-button">< go back to listings</a>
<% if classified_listing.errors.any? %>
<div class="classified-errors">
<h2><%= pluralize(classified_listing.errors.count, "error") %> prohibited this classified_listing from being saved:</h2>
@ -11,32 +11,14 @@
</ul>
</div>
<% end %>
<div class="field">
<%= form.label "title", "Title" %>
<%= form.text_field "title", maxlength: 128, placeholder: "128 characters max, plain text" %>
</div>
<div class="field">
<%= form.label "body_markdown", "Body Markdown" %>
<%= form.text_area "body_markdown", maxlength: 400, placeholder: "400 characters max, 12 line break max, no images allowed" %>
</div>
<div class="field">
<%= form.label "category" %>
<%= form.select :category, options_for_select(ClassifiedListing.select_options_for_categories, ClassifiedListing.select_options_for_categories.first) %>
<details>
<summary>
Category details/rules
</summary>
<% ClassifiedListing.categories_available.keys.each do |key| %>
<p>
<%= ClassifiedListing.categories_available[key][:name] %>: <%= ClassifiedListing.categories_available[key][:rules] %>
</p>
<% end %>
</details>
</div>
<div class="field">
<%= form.label "tag_list", "Tags" %>
<%= form.text_field "tag_list", placeholder: "8 tags max, comma separated, no spaces or special characters" %>
<div id="listingform-data"
data-listing="<%= classified_listing.to_json(only: %i[id title slug cached_tag_list]) %>"
data-organizations="<%= @organizations.to_json %>"
data-categories-for-select="<%= ClassifiedListing.select_options_for_categories.to_json %>"
data-categories-for-details="<%= ClassifiedListing.categories_available.transform_values{ |value_hash| value_hash.except(:cost) }.values.to_json %>"
>
</div>
<%= javascript_pack_tag "listingForm", defer: true %>
<% if false # Put connect button (future feature) %>
<div class="field">
<%= form.label "contact_via_connect" %>

View file

@ -1,4 +1,4 @@
<div class="classifieds-container">
<div class="classifieds-container" id="classifieds-container">
<%= render 'form', classified_listing: @classified_listing %>
</div>