diff --git a/app/assets/stylesheets/classified_listings.scss b/app/assets/stylesheets/classified_listings.scss
index dbd89bf33..012b6be45 100644
--- a/app/assets/stylesheets/classified_listings.scss
+++ b/app/assets/stylesheets/classified_listings.scss
@@ -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;
}
}
-}
\ No newline at end of file
+}
+
+.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;
+}
diff --git a/app/assets/stylesheets/classified_listings_form.scss b/app/assets/stylesheets/classified_listings_form.scss
new file mode 100644
index 000000000..d73a900db
--- /dev/null
+++ b/app/assets/stylesheets/classified_listings_form.scss
@@ -0,0 +1 @@
+@import 'variables';
diff --git a/app/javascript/listings/elements/bodyMarkdown.jsx b/app/javascript/listings/elements/bodyMarkdown.jsx
new file mode 100644
index 000000000..fbc3cebbc
--- /dev/null
+++ b/app/javascript/listings/elements/bodyMarkdown.jsx
@@ -0,0 +1,26 @@
+import { h } from 'preact';
+import PropTypes from 'prop-types';
+
+const BodyMarkdown = ({ onChange, defaultValue }) => (
+
+
+
+)
+
+BodyMarkdown.propTypes = {
+ onChange: PropTypes.func.isRequired,
+ defaultValue: PropTypes.string.isRequired,
+}
+
+export default BodyMarkdown;
diff --git a/app/javascript/listings/elements/categories.jsx b/app/javascript/listings/elements/categories.jsx
new file mode 100644
index 000000000..a2b9372bf
--- /dev/null
+++ b/app/javascript/listings/elements/categories.jsx
@@ -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(
+
+ )
+ })
+ }
+
+ details = () => {
+ const { categoriesForDetails } = this.props
+ const rules = categoriesForDetails.map(category => {
+ const paragraphText = `${category.name}: ${category.rules}`
+ return(
+
+ {paragraphText}
+
+ )
+ })
+
+ return(
+
+
+ Category details/rules
+
+ {rules}
+
+ )
+ }
+
+ render() {
+ return(
+
+
+
+ {this.details()}
+
+ )
+ }
+}
+
+Categories.propTypes = {
+ categoriesForSelect: PropTypes.array.isRequired,
+ categoriesForDetails: PropTypes.array.isRequired,
+}
+
+
+export default Categories;
\ No newline at end of file
diff --git a/app/javascript/listings/elements/organizationField.jsx b/app/javascript/listings/elements/organizationField.jsx
new file mode 100644
index 000000000..e69de29bb
diff --git a/app/javascript/listings/elements/tags.jsx b/app/javascript/listings/elements/tags.jsx
new file mode 100644
index 000000000..1a525e262
--- /dev/null
+++ b/app/javascript/listings/elements/tags.jsx
@@ -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) => (
+
+ {tag.name}
+
+ ));
+ if (
+ this.state.searchResults.length > 0 &&
+ document.activeElement.id === 'tag-input'
+ ) {
+ searchResultsHTML = (
+ {searchResultsRows}
+ );
+ }
+
+ return (
+
+
+ (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}
+
+ );
+ }
+
+ 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;
diff --git a/app/javascript/listings/elements/title.jsx b/app/javascript/listings/elements/title.jsx
new file mode 100644
index 000000000..1f234b311
--- /dev/null
+++ b/app/javascript/listings/elements/title.jsx
@@ -0,0 +1,32 @@
+import PropTypes from 'prop-types';
+import { h } from 'preact';
+
+const domId = 1;
+
+const Title = ( { onChange, defaultValue }) => (
+
+
+
+)
+
+Title.propTypes = {
+ onChange: PropTypes.func.isRequired,
+ defaultValue: PropTypes.string.isRequired,
+}
+
+
+export default Title;
\ No newline at end of file
diff --git a/app/javascript/listings/listingForm.jsx b/app/javascript/listings/listingForm.jsx
new file mode 100644
index 000000000..d8c02f66d
--- /dev/null
+++ b/app/javascript/listings/listingForm.jsx
@@ -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(
+
+
+
+
+
+ {/* add contact via connect checkbox later */}
+
+ )
+ }
+ return(
+
+
+
+
+ {/* add contact via connect checkbox later */}
+
+ )
+ }
+}
diff --git a/app/javascript/packs/listingForm.jsx b/app/javascript/packs/listingForm.jsx
new file mode 100644
index 000000000..0f815ccc2
--- /dev/null
+++ b/app/javascript/packs/listingForm.jsx
@@ -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(
+ ,
+ root,
+);
diff --git a/app/models/classified_listing.rb b/app/models/classified_listing.rb
index e1fa5ebf5..c8203c4ed 100644
--- a/app/models/classified_listing.rb
+++ b/app/models/classified_listing.rb
@@ -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
diff --git a/app/views/classified_listings/_form.html.erb b/app/views/classified_listings/_form.html.erb
index 35eefc1a9..044524fec 100644
--- a/app/views/classified_listings/_form.html.erb
+++ b/app/views/classified_listings/_form.html.erb
@@ -1,5 +1,5 @@
<%= form_with(model: classified_listing, local: true) do |form| %>
- < go back to listings
+ < go back to listings
<% if classified_listing.errors.any? %>
<%= pluralize(classified_listing.errors.count, "error") %> prohibited this classified_listing from being saved:
@@ -11,32 +11,14 @@
<% end %>
-
- <%= form.label "title", "Title" %>
- <%= form.text_field "title", maxlength: 128, placeholder: "128 characters max, plain text" %>
-
-
- <%= form.label "body_markdown", "Body Markdown" %>
- <%= form.text_area "body_markdown", maxlength: 400, placeholder: "400 characters max, 12 line break max, no images allowed" %>
-
-
- <%= form.label "category" %>
- <%= form.select :category, options_for_select(ClassifiedListing.select_options_for_categories, ClassifiedListing.select_options_for_categories.first) %>
-
-
- Category details/rules
-
- <% ClassifiedListing.categories_available.keys.each do |key| %>
-
- <%= ClassifiedListing.categories_available[key][:name] %>: <%= ClassifiedListing.categories_available[key][:rules] %>
-
- <% end %>
-
-
-
- <%= form.label "tag_list", "Tags" %>
- <%= form.text_field "tag_list", placeholder: "8 tags max, comma separated, no spaces or special characters" %>
+
+ <%= javascript_pack_tag "listingForm", defer: true %>
<% if false # Put connect button (future feature) %>
<%= form.label "contact_via_connect" %>
diff --git a/app/views/classified_listings/new.html.erb b/app/views/classified_listings/new.html.erb
index 4f9f63bc5..ea227503e 100644
--- a/app/views/classified_listings/new.html.erb
+++ b/app/views/classified_listings/new.html.erb
@@ -1,4 +1,4 @@
-
+
<%= render 'form', classified_listing: @classified_listing %>