Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: Relations/Tags/TBD Widget Feature #1100

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,12 @@ Thanks goes to these wonderful people ([emoji key](https://github.com/kentcdodds
| [<img src="https://avatars2.githubusercontent.com/u/799360?v=4" width="100px;"/><br /><sub><b>Bryan Robinson</b></sub>](http://bryanlrobinson.com)<br />[📖](https://github.com/netlify/netlify-cms/commits?author=brob "Documentation") | [<img src="https://avatars1.githubusercontent.com/u/561983?v=4" width="100px;"/><br /><sub><b>Darren</b></sub>](https://github.com/dardub)<br />[📖](https://github.com/netlify/netlify-cms/commits?author=dardub "Documentation") | [<img src="https://avatars3.githubusercontent.com/u/576512?v=4" width="100px;"/><br /><sub><b>Richard Pullinger</b></sub>](http://www.richardpullinger.com)<br />[💻](https://github.com/netlify/netlify-cms/commits?author=rpullinger "Code") | [<img src="https://avatars1.githubusercontent.com/u/3718939?v=4" width="100px;"/><br /><sub><b>Taylor Bryant</b></sub>](https://taylorbryant.blog)<br />[📖](https://github.com/netlify/netlify-cms/commits?author=taylorbryant "Documentation") | [<img src="https://avatars3.githubusercontent.com/u/5881826?v=4" width="100px;"/><br /><sub><b>kvanerkelens</b></sub>](https://github.com/kvanerkelens)<br />[📖](https://github.com/netlify/netlify-cms/commits?author=kvanerkelens "Documentation") | [<img src="https://avatars1.githubusercontent.com/u/8291663?v=4" width="100px;"/><br /><sub><b>Patrick Sier</b></sub>](https://patsier.com/)<br />[💻](https://github.com/netlify/netlify-cms/commits?author=pjsier "Code") | [<img src="https://avatars2.githubusercontent.com/u/6132191?v=4" width="100px;"/><br /><sub><b>Christian Nolte</b></sub>](http://noltech.net)<br />[💻](https://github.com/netlify/netlify-cms/commits?author=drlogout "Code") |
| [<img src="https://avatars1.githubusercontent.com/u/3818?v=4" width="100px;"/><br /><sub><b>Edward Betts</b></sub>](http://edwardbetts.com/)<br />[📖](https://github.com/netlify/netlify-cms/commits?author=EdwardBetts "Documentation") | [<img src="https://avatars1.githubusercontent.com/u/8771435?v=4" width="100px;"/><br /><sub><b>Josh Hardman</b></sub>](https://github.com/jhardman0830)<br />[📖](https://github.com/netlify/netlify-cms/commits?author=jhardman0830 "Documentation") | [<img src="https://avatars0.githubusercontent.com/u/11616378?v=4" width="100px;"/><br /><sub><b>Mantas</b></sub>](https://behance.net/mistermantas)<br />[📖](https://github.com/netlify/netlify-cms/commits?author=mistermantas "Documentation") | [<img src="https://avatars0.githubusercontent.com/u/5244986?v=4" width="100px;"/><br /><sub><b>Marco Biedermann</b></sub>](https://www.marcobiedermann.com)<br />[📖](https://github.com/netlify/netlify-cms/commits?author=marcobiedermann "Documentation") | [<img src="https://avatars0.githubusercontent.com/u/7525670?v=4" width="100px;"/><br /><sub><b>Max Stoiber</b></sub>](https://mxstbr.blog/newsletter)<br />[📖](https://github.com/netlify/netlify-cms/commits?author=mxstbr "Documentation") | [<img src="https://avatars0.githubusercontent.com/u/89368?v=4" width="100px;"/><br /><sub><b>Vyacheslav Matyukhin</b></sub>](http://berekuk.ru)<br />[📖](https://github.com/netlify/netlify-cms/commits?author=berekuk "Documentation") | [<img src="https://avatars1.githubusercontent.com/u/9059048?v=4" width="100px;"/><br /><sub><b>jimmaaay</b></sub>](https://jimmythompson.me)<br />[💻](https://github.com/netlify/netlify-cms/commits?author=jimmaaay "Code") |
| [<img src="https://avatars3.githubusercontent.com/u/802086?v=4" width="100px;"/><br /><sub><b>Luís Miguel</b></sub>](https://github.com/Quicksaver)<br />[🐛](https://github.com/netlify/netlify-cms/issues?q=author%3AQuicksaver "Bug reports") [💻](https://github.com/netlify/netlify-cms/commits?author=Quicksaver "Code") | [<img src="https://avatars2.githubusercontent.com/u/357379?v=4" width="100px;"/><br /><sub><b>Chris Swithinbank</b></sub>](http://chrisswithinbank.net/)<br />[📖](https://github.com/netlify/netlify-cms/commits?author=delucis "Documentation") | [<img src="https://avatars3.githubusercontent.com/u/1262221?v=4" width="100px;"/><br /><sub><b>remmah</b></sub>](https://github.com/remmah)<br />[📖](https://github.com/netlify/netlify-cms/commits?author=remmah "Documentation") | [<img src="https://avatars2.githubusercontent.com/u/29388?v=4" width="100px;"/><br /><sub><b>Sumeet Jain</b></sub>](http://sumeetjain.com)<br />[📖](https://github.com/netlify/netlify-cms/commits?author=sumeetjain "Documentation") | [<img src="https://avatars2.githubusercontent.com/u/26816046?v=4" width="100px;"/><br /><sub><b>Sagar Khatri</b></sub>](https://github.com/ragasirtahk)<br />[📖](https://github.com/netlify/netlify-cms/commits?author=ragasirtahk "Documentation") [💡](#example-ragasirtahk "Examples") | [<img src="https://avatars0.githubusercontent.com/u/8182932?v=4" width="100px;"/><br /><sub><b>Kevin Doocey</b></sub>](https://www.dooceykev.in)<br />[💻](https://github.com/netlify/netlify-cms/commits?author=Doocey "Code") | [<img src="https://avatars0.githubusercontent.com/u/31023010?v=4" width="100px;"/><br /><sub><b>Swieckowski</b></sub>](https://www.linkedin.com/in/arthur-swieckowski/)<br />[💻](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
| [<img src="https://avatars2.githubusercontent.com/u/283419?v=4" width="100px;"/><br /><sub><b>Tim Carry</b></sub>](http://www.pixelastic.com/)<br />[💻](https://github.com/netlify/netlify-cms/commits?author=pixelastic "Code") [🎨](#design-pixelastic "Design") [📖](https://github.com/netlify/netlify-cms/commits?author=pixelastic "Documentation") | [<img src="https://avatars0.githubusercontent.com/u/30510616?v=4" width="100px;"/><br /><sub><b>Sol Park</b></sub>](https://github.com/solpark)<br />[💻](https://github.com/netlify/netlify-cms/commits?author=solpark "Code") | [<img src="https://avatars0.githubusercontent.com/u/29218846?v=4" width="100px;"/><br /><sub><b>Michael Romani</b></sub>](https://github.com/michaelromani)<br />[💻](https://github.com/netlify/netlify-cms/commits?author=michaelromani "Code") | [<img src="https://avatars1.githubusercontent.com/u/15175868?v=4" width="100px;"/><br /><sub><b>Xifeng Jin</b></sub>](http://linkedin/in/xifengjin88)<br />[🐛](https://github.com/netlify/netlify-cms/issues?q=author%3Axifengjin88 "Bug reports") [💻](https://github.com/netlify/netlify-cms/commits?author=xifengjin88 "Code") | [<img src="https://avatars1.githubusercontent.com/u/372831?v=4" width="100px;"/><br /><sub><b>Pedro Duarte</b></sub>](http://pedroduarte.me)<br />[🐛](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") | [<img src="https://avatars1.githubusercontent.com/u/6064830?v=4" width="100px;"/><br /><sub><b>Antonio Argote</b></sub>](http://antonioargote.com)<br />[🎨](#design-Strangehill "Design") | [<img src="https://avatars3.githubusercontent.com/u/1479451?v=4" width="100px;"/><br /><sub><b>Kristaps Taube</b></sub>](https://www.ktaube.com)<br />[💻](https://github.com/netlify/netlify-cms/commits?author=ktaube "Code") |
| [<img src="https://avatars3.githubusercontent.com/u/26639499?v=4" width="100px;"/><br /><sub><b>David Ko</b></sub>](https://github.com/daveyko)<br />[💻](https://github.com/netlify/netlify-cms/commits?author=daveyko "Code") | [<img src="https://avatars3.githubusercontent.com/u/440562?v=4" width="100px;"/><br /><sub><b>Iñaki García</b></sub>](http://www.txorua.com)<br />[🎨](#design-igarbla "Design") |
=======
| [<img src="https://avatars2.githubusercontent.com/u/283419?v=4" width="100px;"/><br /><sub><b>Tim Carry</b></sub>](http://www.pixelastic.com/)<br />[💻](https://github.com/netlify/netlify-cms/commits?author=pixelastic "Code") [🎨](#design-pixelastic "Design") [📖](https://github.com/netlify/netlify-cms/commits?author=pixelastic "Documentation") | [<img src="https://avatars0.githubusercontent.com/u/30510616?v=4" width="100px;"/><br /><sub><b>Sol Park</b></sub>](https://github.com/solpark)<br />[💻](https://github.com/netlify/netlify-cms/commits?author=solpark "Code") | [<img src="https://avatars0.githubusercontent.com/u/29218846?v=4" width="100px;"/><br /><sub><b>Michael Romani</b></sub>](https://github.com/michaelromani)<br />[💻](https://github.com/netlify/netlify-cms/commits?author=michaelromani "Code") | [<img src="https://avatars1.githubusercontent.com/u/2730871?v=4" width="100px;"/><br /><sub><b>Alex Moon</b></sub>](https://github.com/moonmeister)<br />[💻](https://github.com/netlify/netlify-cms/commits?author=moonmeister "Code") [📖](https://github.com/netlify/netlify-cms/commits?author=moonmeister "Documentation") |
>>>>>>> add self as contributer
<!-- ALL-CONTRIBUTORS-LIST:END -->

This project follows the [all-contributors](https://github.com/kentcdodds/all-contributors) specification. Contributions of any kind welcome!
8 changes: 8 additions & 0 deletions example/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -154,13 +154,15 @@
"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",
"react-router-redux": "^5.0.0-alpha.8",
"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",
Expand Down
1 change: 1 addition & 0 deletions src/components/EditorWidgets/EditorWidgets.css
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
@import "./Boolean/Boolean.css";
@import "./Relation/Relation.css";
@import "./DateTime/DateTime.css";
@import "./Relations/Relations.css";

:root {
--widgetNestDistance: 14px;
Expand Down
42 changes: 42 additions & 0 deletions src/components/EditorWidgets/Relations/Relations.css
Original file line number Diff line number Diff line change
@@ -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;
}
171 changes: 171 additions & 0 deletions src/components/EditorWidgets/Relations/RelationsControl.js
Original file line number Diff line number Diff line change
@@ -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 (
<div className="nc-relations-container">
<Autosuggest
suggestions={suggestions}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onSuggestionSelected={(e, { suggestion }) => {
const value = this.getSuggestionValue(suggestion);
addTag(value);
}}
getSuggestionValue={this.getSuggestionValue}
renderSuggestion={this.renderSuggestion}
inputProps={{ ...props, inputprops }}
focusInputOnSuggestionClick={false}
/>
<Loader active={this.isFetching === this.controlID} />
</div>
);
};

renderSuggestion = (suggestion) => {
const { field } = this.props;
const valueField = field.get('valueField');
return <span>{suggestion.data[valueField]}</span>;
};

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 (
<TagsInput
id={forID}
className={classNameWrapper}
value={value || []}
onChange={this.onChange}
onFocus={setActiveStyle}
onBlur={setInactiveStyle}
maxTags={limit}
onlyUnique={unique}
inputProps={inputProps}
renderInput={this.autocompleteRenderInput}
/>
);
}
}

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);
18 changes: 18 additions & 0 deletions src/components/EditorWidgets/Relations/RelationsPreview.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import PropTypes from 'prop-types';
import React from 'react';

export default function RelationsPreview({ value, field }) {
return (
<div className="nc-widgetPreview">
{value.map(val =>
<span>{val}</span>
)
}
</div>
);
}

RelationsPreview.propTypes = {
value: PropTypes.node,
field: PropTypes.node,
};
3 changes: 3 additions & 0 deletions src/components/EditorWidgets/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
36 changes: 36 additions & 0 deletions website/site/content/docs/widgets/relations.md
Original file line number Diff line number Diff line change
@@ -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.
Loading