diff --git a/README.md b/README.md index 4a1a1a441a87..e22c814fe7b2 100644 --- a/README.md +++ b/README.md @@ -62,8 +62,12 @@ Thanks goes to these wonderful people ([emoji key](https://github.com/kentcdodds | [
Bryan Robinson](http://bryanlrobinson.com)
[πŸ“–](https://github.com/netlify/netlify-cms/commits?author=brob "Documentation") | [
Darren](https://github.com/dardub)
[πŸ“–](https://github.com/netlify/netlify-cms/commits?author=dardub "Documentation") | [
Richard Pullinger](http://www.richardpullinger.com)
[πŸ’»](https://github.com/netlify/netlify-cms/commits?author=rpullinger "Code") | [
Taylor Bryant](https://taylorbryant.blog)
[πŸ“–](https://github.com/netlify/netlify-cms/commits?author=taylorbryant "Documentation") | [
kvanerkelens](https://github.com/kvanerkelens)
[πŸ“–](https://github.com/netlify/netlify-cms/commits?author=kvanerkelens "Documentation") | [
Patrick Sier](https://patsier.com/)
[πŸ’»](https://github.com/netlify/netlify-cms/commits?author=pjsier "Code") | [
Christian Nolte](http://noltech.net)
[πŸ’»](https://github.com/netlify/netlify-cms/commits?author=drlogout "Code") | | [
Edward Betts](http://edwardbetts.com/)
[πŸ“–](https://github.com/netlify/netlify-cms/commits?author=EdwardBetts "Documentation") | [
Josh Hardman](https://github.com/jhardman0830)
[πŸ“–](https://github.com/netlify/netlify-cms/commits?author=jhardman0830 "Documentation") | [
Mantas](https://behance.net/mistermantas)
[πŸ“–](https://github.com/netlify/netlify-cms/commits?author=mistermantas "Documentation") | [
Marco Biedermann](https://www.marcobiedermann.com)
[πŸ“–](https://github.com/netlify/netlify-cms/commits?author=marcobiedermann "Documentation") | [
Max Stoiber](https://mxstbr.blog/newsletter)
[πŸ“–](https://github.com/netlify/netlify-cms/commits?author=mxstbr "Documentation") | [
Vyacheslav Matyukhin](http://berekuk.ru)
[πŸ“–](https://github.com/netlify/netlify-cms/commits?author=berekuk "Documentation") | [
jimmaaay](https://jimmythompson.me)
[πŸ’»](https://github.com/netlify/netlify-cms/commits?author=jimmaaay "Code") | | [
LuΓ­s Miguel](https://github.com/Quicksaver)
[πŸ›](https://github.com/netlify/netlify-cms/issues?q=author%3AQuicksaver "Bug reports") [πŸ’»](https://github.com/netlify/netlify-cms/commits?author=Quicksaver "Code") | [
Chris Swithinbank](http://chrisswithinbank.net/)
[πŸ“–](https://github.com/netlify/netlify-cms/commits?author=delucis "Documentation") | [
remmah](https://github.com/remmah)
[πŸ“–](https://github.com/netlify/netlify-cms/commits?author=remmah "Documentation") | [
Sumeet Jain](http://sumeetjain.com)
[πŸ“–](https://github.com/netlify/netlify-cms/commits?author=sumeetjain "Documentation") | [
Sagar Khatri](https://github.com/ragasirtahk)
[πŸ“–](https://github.com/netlify/netlify-cms/commits?author=ragasirtahk "Documentation") [πŸ’‘](#example-ragasirtahk "Examples") | [
Kevin Doocey](https://www.dooceykev.in)
[πŸ’»](https://github.com/netlify/netlify-cms/commits?author=Doocey "Code") | [
Swieckowski](https://www.linkedin.com/in/arthur-swieckowski/)
[πŸ’»](https://github.com/netlify/netlify-cms/commits?author=Swieckowski "Code") [πŸ“–](https://github.com/netlify/netlify-cms/commits?author=Swieckowski "Documentation") [⚠️](https://github.com/netlify/netlify-cms/commits?author=Swieckowski "Tests") | +<<<<<<< 52b242e65946992775823cd903201b3dbd6fb5be | [
Tim Carry](http://www.pixelastic.com/)
[πŸ’»](https://github.com/netlify/netlify-cms/commits?author=pixelastic "Code") [🎨](#design-pixelastic "Design") [πŸ“–](https://github.com/netlify/netlify-cms/commits?author=pixelastic "Documentation") | [
Sol Park](https://github.com/solpark)
[πŸ’»](https://github.com/netlify/netlify-cms/commits?author=solpark "Code") | [
Michael Romani](https://github.com/michaelromani)
[πŸ’»](https://github.com/netlify/netlify-cms/commits?author=michaelromani "Code") | [
Xifeng Jin](http://linkedin/in/xifengjin88)
[πŸ›](https://github.com/netlify/netlify-cms/issues?q=author%3Axifengjin88 "Bug reports") [πŸ’»](https://github.com/netlify/netlify-cms/commits?author=xifengjin88 "Code") | [
Pedro Duarte](http://pedroduarte.me)
[πŸ›](https://github.com/netlify/netlify-cms/issues?q=author%3Apeduarte "Bug reports") [πŸ’»](https://github.com/netlify/netlify-cms/commits?author=peduarte "Code") [πŸ“–](https://github.com/netlify/netlify-cms/commits?author=peduarte "Documentation") | [
Antonio Argote](http://antonioargote.com)
[🎨](#design-Strangehill "Design") | [
Kristaps Taube](https://www.ktaube.com)
[πŸ’»](https://github.com/netlify/netlify-cms/commits?author=ktaube "Code") | | [
David Ko](https://github.com/daveyko)
[πŸ’»](https://github.com/netlify/netlify-cms/commits?author=daveyko "Code") | [
IΓ±aki GarcΓ­a](http://www.txorua.com)
[🎨](#design-igarbla "Design") | +======= +| [
Tim Carry](http://www.pixelastic.com/)
[πŸ’»](https://github.com/netlify/netlify-cms/commits?author=pixelastic "Code") [🎨](#design-pixelastic "Design") [πŸ“–](https://github.com/netlify/netlify-cms/commits?author=pixelastic "Documentation") | [
Sol Park](https://github.com/solpark)
[πŸ’»](https://github.com/netlify/netlify-cms/commits?author=solpark "Code") | [
Michael Romani](https://github.com/michaelromani)
[πŸ’»](https://github.com/netlify/netlify-cms/commits?author=michaelromani "Code") | [
Alex Moon](https://github.com/moonmeister)
[πŸ’»](https://github.com/netlify/netlify-cms/commits?author=moonmeister "Code") [πŸ“–](https://github.com/netlify/netlify-cms/commits?author=moonmeister "Documentation") | +>>>>>>> add self as contributer This project follows the [all-contributors](https://github.com/kentcdodds/all-contributors) specification. Contributions of any kind welcome! diff --git a/example/config.yml b/example/config.yml index 9e245c33c788..a282f7b9cb6d 100644 --- a/example/config.yml +++ b/example/config.yml @@ -68,6 +68,14 @@ collections: # A list of collections the CMS should be able to edit folder: "_sink" create: true fields: + - label: "Tags" + name: "tags" + widget: "relations" + limit: "3" + collection: "posts" + searchFields: ["title"] + valueField: "title" + - label: "Related Post" name: "post" widget: "relationKitchenSinkPost" diff --git a/package.json b/package.json index 411396bc65fc..4a0adacdb0ec 100644 --- a/package.json +++ b/package.json @@ -154,6 +154,7 @@ "react-dom": "^16.0.0", "react-frame-component": "^2.0.0", "react-immutable-proptypes": "^2.1.0", + "react-input-autosize": "^2.2.1", "react-modal": "^3.1.5", "react-redux": "^4.4.0", "react-router-dom": "^4.2.2", @@ -161,6 +162,7 @@ "react-scroll-sync": "^0.4.0", "react-sortable-hoc": "^0.6.8", "react-split-pane": "^0.1.66", + "react-tagsinput": "^3.19.0", "react-textarea-autosize": "^5.2.0", "react-toggled": "^1.1.2", "react-topbar-progress-indicator": "^2.0.0", diff --git a/src/components/EditorWidgets/EditorWidgets.css b/src/components/EditorWidgets/EditorWidgets.css index 1c7eec76c7ba..05fc5a55ea55 100644 --- a/src/components/EditorWidgets/EditorWidgets.css +++ b/src/components/EditorWidgets/EditorWidgets.css @@ -7,6 +7,7 @@ @import "./Boolean/Boolean.css"; @import "./Relation/Relation.css"; @import "./DateTime/DateTime.css"; +@import "./Relations/Relations.css"; :root { --widgetNestDistance: 14px; diff --git a/src/components/EditorWidgets/Relations/Relations.css b/src/components/EditorWidgets/Relations/Relations.css new file mode 100644 index 000000000000..03cfab850911 --- /dev/null +++ b/src/components/EditorWidgets/Relations/Relations.css @@ -0,0 +1,42 @@ +@import '../Relation/ReactAutosuggest.css'; + +.react-tagsinput-tag { + border: 1px solid var(--colorActive); + border-radius: var(--borderWidth); + display: inline-block; + background-color: var(--colorBackground); + margin: 2.5px; + padding: 5px; + color: var(--colorTextLead); + font-size: 13px; + font-family: var(--fontFamilyMono); + font-weight: 400; +} + +.react-tagsinput-remove { + font-weight: bold; + cursor: pointer; +} + +.react-tagsinput-tag a::before { + content: ' Γ—'; +} + +.react-tagsinput-input { + border: 0; + margin-top: auto; + margin-bottom: auto; + background: var(--colorInputBackground); + padding: 5px; + outline: none; + width: auto; + color: var(--colorTextLead); + font-size: 13px; + font-weight: 400; + font-family: var(--fontFamilyPrimary); +} + +.nc-relations-container { + display: inline-block; + width: auto; +} diff --git a/src/components/EditorWidgets/Relations/RelationsControl.js b/src/components/EditorWidgets/Relations/RelationsControl.js new file mode 100644 index 000000000000..d74647eb8a44 --- /dev/null +++ b/src/components/EditorWidgets/Relations/RelationsControl.js @@ -0,0 +1,171 @@ +import React, { Component } from 'react'; +import { Loader } from 'UI'; +import TagsInput from 'react-tagsinput'; +import PropTypes from 'prop-types'; +import Autosuggest from 'react-autosuggest'; +import uuid from 'uuid/v4'; +import { Map } from 'immutable'; +import { connect } from 'react-redux'; +import { debounce } from 'lodash'; +import { query, clearSearch } from 'Actions/search'; + +class RelationsControl extends Component { + static propTypes = { + onChange: PropTypes.func.isRequired, + forID: PropTypes.string, + value: PropTypes.array, + field: PropTypes.node, + isFetching: PropTypes.node, + query: PropTypes.func.isRequired, + clearSearch: PropTypes.func.isRequired, + queryHits: PropTypes.oneOfType([ + PropTypes.array, + PropTypes.object, + ]), + classNameWrapper: PropTypes.string.isRequired, + setActiveStyle: PropTypes.func.isRequired, + setInactiveStyle: PropTypes.func.isRequired, + }; + + constructor(props, ctx) { + super(props, ctx); + this.controlID = uuid(); + this.didInitialSearch = false; + } + + componentDidMount() { + const { value, field } = this.props; + if (value) { + const collection = field.get('collection'); + const searchFields = field.get('searchFields').toJS(); + this.props.query(this.controlID, collection, searchFields, value); + } + } + + componentWillReceiveProps(nextProps) { + if (this.didInitialSearch) return; + if (nextProps.queryHits !== this.props.queryHits && nextProps.queryHits.get && nextProps.queryHits.get(this.controlID)) { + this.didInitialSearch = true; + const suggestion = nextProps.queryHits.get(this.controlID); + if (suggestion && suggestion.length === 1) { + const val = this.getSuggestionValue(suggestion[0]); + this.props.onChange(val, { [nextProps.field.get('collection')]: { [val]: suggestion[0].data } }); + } + } + } + + onChange = (relations) => { + this.props.onChange(relations.map(val => val)); + }; + + onSuggestionsFetchRequested = debounce(({ value }) => { + if (value.length < 2) return; + const { field } = this.props; + const collection = field.get('collection'); + const searchFields = field.get('searchFields').toJS(); + this.props.query(this.controlID, collection, searchFields, value); + }, 500); + + onSuggestionsClearRequested = () => { + this.props.clearSearch(); + }; + + getSuggestionValue = (suggestion) => { + const { field } = this.props; + const valueField = field.get('valueField'); + return suggestion.data[valueField]; + }; + + autocompleteRenderInput = ({ addTag, ...props }) => { + const handleOnChange = (e, { newValue, method }) => { + if (method === 'enter') { + e.preventDefault(); + } else { + this.onChange(e); + } + }; + + const inputprops = { + placeholder: props.placeholder, + onChange: handleOnChange, + }; + + const suggestions = (this.props.queryHits.get) ? this.props.queryHits.get(this.controlID, []) : []; + + return ( +
+ { + const value = this.getSuggestionValue(suggestion); + addTag(value); + }} + getSuggestionValue={this.getSuggestionValue} + renderSuggestion={this.renderSuggestion} + inputProps={{ ...props, inputprops }} + focusInputOnSuggestionClick={false} + /> + +
+ ); + }; + + renderSuggestion = (suggestion) => { + const { field } = this.props; + const valueField = field.get('valueField'); + return {suggestion.data[valueField]}; + }; + + render() { + // Tags Input Variable Initialization. + const { + forID, + value, + classNameWrapper, + setActiveStyle, + setInactiveStyle, + } = this.props; + + const limit = this.props.field.get("limit", "-1"); + const unique = this.props.field.get("unique", false); + const inputProps = { + placeholder: this.props.field.get("placeholder", 'Add a Relation'), + }; + + return ( + + ); + } +} + +function mapStateToProps(state, ownProps) { + const { className } = ownProps; + const isFetching = state.search.get('isFetching'); + const queryHits = state.search.get('queryHits'); + return { isFetching, queryHits, className }; +} + +export default connect( + mapStateToProps, + { + query, + clearSearch, + }, + null, + { + withRef: true, + } +)(RelationsControl); diff --git a/src/components/EditorWidgets/Relations/RelationsPreview.js b/src/components/EditorWidgets/Relations/RelationsPreview.js new file mode 100644 index 000000000000..781b57522958 --- /dev/null +++ b/src/components/EditorWidgets/Relations/RelationsPreview.js @@ -0,0 +1,18 @@ +import PropTypes from 'prop-types'; +import React from 'react'; + +export default function RelationsPreview({ value, field }) { + return ( +
+ {value.map(val => + {val} + ) + } +
+ ); +} + +RelationsPreview.propTypes = { + value: PropTypes.node, + field: PropTypes.node, +}; diff --git a/src/components/EditorWidgets/index.js b/src/components/EditorWidgets/index.js index 94dc6c715c7e..a715444fd61c 100644 --- a/src/components/EditorWidgets/index.js +++ b/src/components/EditorWidgets/index.js @@ -26,6 +26,8 @@ import ObjectPreview from './Object/ObjectPreview'; import RelationControl from './Relation/RelationControl'; import RelationPreview from './Relation/RelationPreview'; import BooleanControl from './Boolean/BooleanControl'; +import RelationsControl from './Relations/RelationsControl'; +import RelationsPreview from './Relations/RelationsPreview'; registerWidget('string', StringControl, StringPreview); @@ -41,4 +43,5 @@ registerWidget('select', SelectControl, SelectPreview); registerWidget('object', ObjectControl, ObjectPreview); registerWidget('relation', RelationControl, RelationPreview); registerWidget('boolean', BooleanControl); +registerWidget('relations', RelationsControl, RelationsPreview); registerWidget('unknown', UnknownControl, UnknownPreview); diff --git a/website/site/content/docs/widgets/relations.md b/website/site/content/docs/widgets/relations.md new file mode 100644 index 000000000000..21f7ab38779d --- /dev/null +++ b/website/site/content/docs/widgets/relations.md @@ -0,0 +1,36 @@ +--- +label: "Relations" +target: "relations" +type: "widget" +--- + +### Relations + +The relations widget allows you to reference items from another collection. It provides a search input with a list of entries from the collection you're referencing, and the list automatically updates with matched entries based on what you've typed. + +- **Name:** `relations` +- **UI:** text input with search result dropdown +- **Data type:** data type of the value pulled from the related collection item +- **Options:** + - `default`: accepts any widget data type; defaults to an empty string + - `collection`: (**required**) name of the collection being referenced (string) + - `searchFields`: (**required**) list of one or more names of fields in the referenced collection to search for the typed value + - `valueField`: (**required**) name of the field from the referenced collection whose value will be stored for the relation + - `limit`: limits the number of relations. defaults to unlimited relations. (number) + - `unique`: only allow unique relations. defaults to false. (boolean) + - `placeholder`: the placeholder text in the input field. defaults to "Add a Relation". (string) + +- **Example** (assuming a separate "authors" collection with "name" and "twitterHandle" fields): + + ```yaml + - label: "Post Author" + name: "author" + widget: "relation" + limit: "4" + unique: true + placeholder: "Add Author Here" + collection: "authors" + searchFields: ["name", "twitterHandle"] + valueField: "name" + ``` + The generated UI input will search the authors collection by name and twitterHandle as the user types. On selection, the author name will be saved for the field. diff --git a/yarn.lock b/yarn.lock index 43bd0a379cca..2af6735c0dc8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7403,6 +7403,12 @@ react-immutable-proptypes@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/react-immutable-proptypes/-/react-immutable-proptypes-2.1.0.tgz#023d6f39bb15c97c071e9e60d00d136eac5fa0b4" +react-input-autosize@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/react-input-autosize/-/react-input-autosize-2.2.1.tgz#ec428fa15b1592994fb5f9aa15bb1eb6baf420f8" + dependencies: + prop-types "^15.5.8" + react-modal@^3.1.5: version "3.1.6" resolved "https://registry.yarnpkg.com/react-modal/-/react-modal-3.1.6.tgz#82e63f1ec86b80e242518250d066ee37fa035f8a" @@ -7492,6 +7498,10 @@ react-style-proptype@^3.0.0: dependencies: prop-types "^15.5.4" +react-tagsinput@^3.19.0: + version "3.19.0" + resolved "https://registry.yarnpkg.com/react-tagsinput/-/react-tagsinput-3.19.0.tgz#6e3b45595f2d295d4657bf194491988f948caabf" + react-test-renderer@^16.0.0, react-test-renderer@^16.0.0-0: version "16.1.1" resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.1.1.tgz#a05184688d564be799f212449262525d1e350537"