From ddfdc59941fd0ae7a64f2afe2d6cdc3425a17660 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A1ssio=20Souza?= Date: Thu, 29 Dec 2016 17:18:24 -0200 Subject: [PATCH] Add metadata to draft entry fields (#196) * Add metadata to draft entry fields * Do not render widget if value is null * Pass along metadata * Namespace queries to avoid conflict * Query relational field on mount (for when editing entries) * Make sure metadata is Immutable * Added collection name as metadata keys --- src/actions/entries.js | 12 ++++- src/actions/search.js | 25 +++++---- src/components/ControlPanel/ControlPane.js | 7 ++- src/components/EntryEditor/EntryEditor.js | 4 ++ src/components/PreviewPane/PreviewPane.js | 6 ++- src/components/Widgets/ListControl.js | 4 +- src/components/Widgets/ObjectControl.js | 4 +- src/components/Widgets/RelationControl.js | 59 ++++++++++++++++++---- src/containers/EntryPage.js | 11 ++-- src/reducers/__tests__/entryDraft.spec.js | 6 ++- src/reducers/entryDraft.js | 14 +++-- src/reducers/search.js | 4 +- 12 files changed, 112 insertions(+), 44 deletions(-) diff --git a/src/actions/entries.js b/src/actions/entries.js index c80cf482..0b46abf8 100644 --- a/src/actions/entries.js +++ b/src/actions/entries.js @@ -22,6 +22,7 @@ export const DRAFT_CREATE_FROM_ENTRY = 'DRAFT_CREATE_FROM_ENTRY'; export const DRAFT_CREATE_EMPTY = 'DRAFT_CREATE_EMPTY'; export const DRAFT_DISCARD = 'DRAFT_DISCARD'; export const DRAFT_CHANGE = 'DRAFT_CHANGE'; +export const DRAFT_CHANGE_FIELD = 'DRAFT_CHANGE_FIELD'; export const ENTRY_PERSIST_REQUEST = 'ENTRY_PERSIST_REQUEST'; export const ENTRY_PERSIST_SUCCESS = 'ENTRY_PERSIST_SUCCESS'; @@ -112,7 +113,7 @@ export function entryPersistFail(collection, entry, error) { }; } -export function emmptyDraftCreated(entry) { +export function emptyDraftCreated(entry) { return { type: DRAFT_CREATE_EMPTY, payload: entry, @@ -141,6 +142,13 @@ export function changeDraft(entry) { }; } +export function changeDraftField(field, value, metadata) { + return { + type: DRAFT_CHANGE_FIELD, + payload: { field, value, metadata }, + }; +} + /* * Exported Thunk Action Creators */ @@ -180,7 +188,7 @@ export function createEmptyDraft(collection) { dataFields[field.get('name')] = field.get('default', null); }); const newEntry = createEntry(collection.get('name'), '', '', { data: dataFields }); - dispatch(emmptyDraftCreated(newEntry)); + dispatch(emptyDraftCreated(newEntry)); }; } diff --git a/src/actions/search.js b/src/actions/search.js index b87b55b3..4180c9f8 100644 --- a/src/actions/search.js +++ b/src/actions/search.js @@ -9,9 +9,9 @@ export const SEARCH_ENTRIES_REQUEST = 'SEARCH_ENTRIES_REQUEST'; export const SEARCH_ENTRIES_SUCCESS = 'SEARCH_ENTRIES_SUCCESS'; export const SEARCH_ENTRIES_FAILURE = 'SEARCH_ENTRIES_FAILURE'; -export const QUERY_REQUEST = 'QUERY_REQUEST'; -export const QUERY_SUCCESS = 'QUERY_SUCCESS'; -export const QUERY_FAILURE = 'QUERY_FAILURE'; +export const QUERY_REQUEST = 'INIT_QUERY'; +export const QUERY_SUCCESS = 'QUERY_OK'; +export const QUERY_FAILURE = 'QUERY_ERROR'; export const SEARCH_CLEAR = 'SEARCH_CLEAR'; @@ -47,10 +47,11 @@ export function searchFailure(searchTerm, error) { }; } -export function querying(collection, searchFields, searchTerm) { +export function querying(namespace, collection, searchFields, searchTerm) { return { type: QUERY_REQUEST, payload: { + namespace, collection, searchFields, searchTerm, @@ -58,10 +59,11 @@ export function querying(collection, searchFields, searchTerm) { }; } -export function querySuccess(collection, searchFields, searchTerm, response) { +export function querySuccess(namespace, collection, searchFields, searchTerm, response) { return { type: QUERY_SUCCESS, payload: { + namespace, collection, searchFields, searchTerm, @@ -70,10 +72,11 @@ export function querySuccess(collection, searchFields, searchTerm, response) { }; } -export function queryFailure(collection, searchFields, searchTerm, error) { +export function queryFailure(namespace, collection, searchFields, searchTerm, error) { return { type: QUERY_SUCCESS, payload: { + namespace, collection, searchFields, searchTerm, @@ -118,20 +121,20 @@ export function searchEntries(searchTerm, page = 0) { // Instead of searching for complete entries, query will search for specific fields // in specific collections and return raw data (no entries). -export function query(collection, searchFields, searchTerm) { +export function query(namespace, collection, searchFields, searchTerm) { return (dispatch, getState) => { const state = getState(); const integration = selectIntegration(state, collection, 'search'); if (!integration) { - dispatch(searchFailure(searchTerm, 'Search integration is not configured.')); + dispatch(searchFailure(namespace, searchTerm, 'Search integration is not configured.')); } const provider = integration ? getIntegrationProvider(state.integrations, integration) : currentBackend(state.config); - dispatch(querying(collection, searchFields, searchTerm)); + dispatch(querying(namespace, collection, searchFields, searchTerm)); provider.searchBy(searchFields, collection, searchTerm).then( - response => dispatch(querySuccess(collection, searchFields, searchTerm, response)), - error => dispatch(queryFailure(collection, searchFields, searchTerm, error)) + response => dispatch(querySuccess(namespace, collection, searchFields, searchTerm, response)), + error => dispatch(queryFailure(namespace, collection, searchFields, searchTerm, error)) ); }; } diff --git a/src/components/ControlPanel/ControlPane.js b/src/components/ControlPanel/ControlPane.js index 493c3da7..202e7f7d 100644 --- a/src/components/ControlPanel/ControlPane.js +++ b/src/components/ControlPanel/ControlPane.js @@ -10,10 +10,11 @@ function isHidden(field) { export default class ControlPane extends Component { controlFor(field) { - const { entry, getMedia, onChange, onAddMedia, onRemoveMedia } = this.props; + const { entry, fieldsMetaData, getMedia, onChange, onAddMedia, onRemoveMedia } = this.props; const widget = resolveWidget(field.get('widget')); const fieldName = field.get('name'); const value = entry.getIn(['data', fieldName]); + const metadata = fieldsMetaData.get(fieldName); if (entry.size === 0 || entry.get('partial') === true) return null; return (
@@ -22,7 +23,8 @@ export default class ControlPane extends Component { React.createElement(widget.control, { field, value, - onChange: val => onChange(entry.setIn(['data', fieldName], val)), + metadata, + onChange: (newValue, newMetadata) => onChange(fieldName, newValue, newMetadata), onAddMedia, onRemoveMedia, getMedia, @@ -57,6 +59,7 @@ ControlPane.propTypes = { collection: ImmutablePropTypes.map.isRequired, entry: ImmutablePropTypes.map.isRequired, fields: ImmutablePropTypes.list.isRequired, + fieldsMetaData: ImmutablePropTypes.map.isRequired, getMedia: PropTypes.func.isRequired, onAddMedia: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired, diff --git a/src/components/EntryEditor/EntryEditor.js b/src/components/EntryEditor/EntryEditor.js index 553beed7..8da72618 100644 --- a/src/components/EntryEditor/EntryEditor.js +++ b/src/components/EntryEditor/EntryEditor.js @@ -25,6 +25,7 @@ class EntryEditor extends Component { collection, entry, fields, + fieldsMetaData, getMedia, onChange, onAddMedia, @@ -51,6 +52,7 @@ class EntryEditor extends Component { collection={collection} entry={entry} fields={fields} + fieldsMetaData={fieldsMetaData} getMedia={getMedia} onChange={onChange} onAddMedia={onAddMedia} @@ -64,6 +66,7 @@ class EntryEditor extends Component { collection={collection} entry={entry} fields={fields} + fieldsMetaData={fieldsMetaData} getMedia={getMedia} />
@@ -87,6 +90,7 @@ EntryEditor.propTypes = { collection: ImmutablePropTypes.map.isRequired, entry: ImmutablePropTypes.map.isRequired, fields: ImmutablePropTypes.list.isRequired, + fieldsMetaData: ImmutablePropTypes.map.isRequired, getMedia: PropTypes.func.isRequired, onAddMedia: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired, diff --git a/src/components/PreviewPane/PreviewPane.js b/src/components/PreviewPane/PreviewPane.js index dbb0995c..6d82ab54 100644 --- a/src/components/PreviewPane/PreviewPane.js +++ b/src/components/PreviewPane/PreviewPane.js @@ -29,20 +29,23 @@ export default class PreviewPane extends React.Component { } widgetFor = (name) => { - const { fields, entry, getMedia } = this.props; + const { fields, entry, fieldsMetaData, getMedia } = this.props; const field = fields.find(f => f.get('name') === name); let value = entry.getIn(['data', field.get('name')]); + const metadata = fieldsMetaData.get(field.get('name')); const labelledWidgets = ['string', 'text', 'number']; if (Object.keys(this.inferedFields).indexOf(name) !== -1) { value = this.inferedFields[name].defaultPreview(value); } else if (value && labelledWidgets.indexOf(field.get('widget')) !== -1 && value.toString().length < 50) { value =
{field.get('label')}: {value}
; } + if (!value) return null; const widget = resolveWidget(field.get('widget')); return React.createElement(widget.preview, { key: field.get('name'), value, field, + metadata, getMedia, }); }; @@ -100,5 +103,6 @@ PreviewPane.propTypes = { collection: ImmutablePropTypes.map.isRequired, fields: ImmutablePropTypes.list.isRequired, entry: ImmutablePropTypes.map.isRequired, + fieldsMetaData: ImmutablePropTypes.map.isRequired, getMedia: PropTypes.func.isRequired, }; diff --git a/src/components/Widgets/ListControl.js b/src/components/Widgets/ListControl.js index 4f5e8202..2b3e71bd 100644 --- a/src/components/Widgets/ListControl.js +++ b/src/components/Widgets/ListControl.js @@ -83,10 +83,10 @@ export default class ListControl extends Component { }; handleChangeFor(index) { - return (newValue) => { + return (newValue, newMetadata) => { const { value, onChange } = this.props; const parsedValue = (this.valueType === valueTypes.SINGLE) ? newValue.first() : newValue; - onChange(value.set(index, parsedValue)); + onChange(value.set(index, parsedValue), newMetadata); }; } diff --git a/src/components/Widgets/ObjectControl.js b/src/components/Widgets/ObjectControl.js index dc1c61c6..28f2c850 100644 --- a/src/components/Widgets/ObjectControl.js +++ b/src/components/Widgets/ObjectControl.js @@ -25,8 +25,8 @@ export default class ObjectControl extends Component { React.createElement(widget.control, { field, value: fieldValue, - onChange: (val) => { - onChange((value || Map()).set(field.get('name'), val)); + onChange: (val, metadata) => { + onChange((value || Map()).set(field.get('name'), val), metadata); }, onAddMedia, onRemoveMedia, diff --git a/src/components/Widgets/RelationControl.js b/src/components/Widgets/RelationControl.js index d5a2bd15..ee740fc6 100644 --- a/src/components/Widgets/RelationControl.js +++ b/src/components/Widgets/RelationControl.js @@ -1,5 +1,7 @@ import React, { Component, PropTypes } from 'react'; import Autosuggest from 'react-autosuggest'; +import uuid from 'uuid'; +import { Map } from 'immutable'; import { connect } from 'react-redux'; import { debounce } from 'lodash'; import { Loader } from '../../components/UI/index'; @@ -15,22 +17,57 @@ class RelationControl extends Component { onChange: PropTypes.func.isRequired, value: PropTypes.node, field: PropTypes.node, - isFetching: PropTypes.bool, + isFetching: PropTypes.node, query: PropTypes.func.isRequired, clearSearch: PropTypes.func.isRequired, - queryHits: PropTypes.array, // eslint-disable-line + queryHits: PropTypes.oneOfType([ + PropTypes.array, + PropTypes.object, + ]), }; + constructor(props, ctx) { + super(props, ctx); + this.controlID = uuid.v4(); + this.didInitialSearch = false; + } + + componentDidMount() { + const { value, field } = this.props; + if (value) { + const collection = field.get('collection'); + const searchFields = field.get('searchFields').map(f => `data.${ f }`).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 = (event, { newValue }) => { this.props.onChange(newValue); }; + onSuggestionSelected = (event, { suggestion }) => { + const value = this.getSuggestionValue(suggestion); + this.props.onChange(value, { [this.props.field.get('collection')]: { [value]: suggestion.data } }); + }; + onSuggestionsFetchRequested = debounce(({ value }) => { - if (value.length < 3) return; + if (value.length < 2) return; const { field } = this.props; const collection = field.get('collection'); const searchFields = field.get('searchFields').map(f => `data.${ f }`).toJS(); - this.props.query(collection, searchFields, value); + this.props.query(this.controlID, collection, searchFields, value); }, 80); onSuggestionsClearRequested = () => { @@ -46,8 +83,7 @@ class RelationControl extends Component { if (escapedValue === '') { return []; } - - return queryHits.filter((hit) => { + return queryHits.get(this.controlID).filter((hit) => { let testResult = false; searchFields.forEach((f) => { testResult = testResult || regex.test(hit.data[f]); @@ -77,17 +113,20 @@ class RelationControl extends Component { onChange: this.onChange, }; + const suggestions = (queryHits.get) ? queryHits.get(this.controlID, []) : []; + return (
- +
); } diff --git a/src/containers/EntryPage.js b/src/containers/EntryPage.js index cce48003..d9972b09 100644 --- a/src/containers/EntryPage.js +++ b/src/containers/EntryPage.js @@ -6,7 +6,7 @@ import { createDraftFromEntry, createEmptyDraft, discardDraft, - changeDraft, + changeDraftField, persistEntry, } from '../actions/entries'; import { cancelEdit } from '../actions/editor'; @@ -22,7 +22,7 @@ class EntryPage extends React.Component { static propTypes = { addMedia: PropTypes.func.isRequired, boundGetMedia: PropTypes.func.isRequired, - changeDraft: PropTypes.func.isRequired, + changeDraftField: PropTypes.func.isRequired, collection: ImmutablePropTypes.map.isRequired, createDraftFromEntry: PropTypes.func.isRequired, createEmptyDraft: PropTypes.func.isRequired, @@ -79,7 +79,7 @@ class EntryPage extends React.Component { fields, boundGetMedia, collection, - changeDraft, + changeDraftField, addMedia, removeMedia, cancelEdit, @@ -97,7 +97,8 @@ class EntryPage extends React.Component { getMedia={boundGetMedia} collection={collection} fields={fields} - onChange={changeDraft} + fieldsMetaData={entryDraft.get('fieldsMetaData')} + onChange={changeDraftField} onAddMedia={addMedia} onRemoveMedia={removeMedia} onPersist={this.handlePersistEntry} @@ -130,7 +131,7 @@ function mapStateToProps(state, ownProps) { export default connect( mapStateToProps, { - changeDraft, + changeDraftField, addMedia, removeMedia, loadEntry, diff --git a/src/reducers/__tests__/entryDraft.spec.js b/src/reducers/__tests__/entryDraft.spec.js index d9c76705..75b3ad10 100644 --- a/src/reducers/__tests__/entryDraft.spec.js +++ b/src/reducers/__tests__/entryDraft.spec.js @@ -2,7 +2,7 @@ import { Map, List, fromJS } from 'immutable'; import * as actions from '../../actions/entries'; import reducer from '../entryDraft'; -let initialState = Map({ entry: Map(), mediaFiles: List() }); +let initialState = Map({ entry: Map(), mediaFiles: List(), fieldsMetaData: Map() }); const entry = { collection: 'posts', @@ -29,6 +29,7 @@ describe('entryDraft reducer', () => { newRecord: false, }, mediaFiles: [], + fieldsMetaData: Map(), }) ); }); @@ -39,7 +40,7 @@ describe('entryDraft reducer', () => { expect( reducer( initialState, - actions.emmptyDraftCreated(fromJS(entry)) + actions.emptyDraftCreated(fromJS(entry)) ) ).toEqual( fromJS({ @@ -48,6 +49,7 @@ describe('entryDraft reducer', () => { newRecord: true, }, mediaFiles: [], + fieldsMetaData: Map(), }) ); }); diff --git a/src/reducers/entryDraft.js b/src/reducers/entryDraft.js index a617932d..a8bd970d 100644 --- a/src/reducers/entryDraft.js +++ b/src/reducers/entryDraft.js @@ -3,7 +3,7 @@ import { DRAFT_CREATE_FROM_ENTRY, DRAFT_CREATE_EMPTY, DRAFT_DISCARD, - DRAFT_CHANGE, + DRAFT_CHANGE_FIELD, ENTRY_PERSIST_REQUEST, ENTRY_PERSIST_SUCCESS, ENTRY_PERSIST_FAILURE, @@ -13,7 +13,7 @@ import { REMOVE_MEDIA, } from '../actions/media'; -const initialState = Map({ entry: Map(), mediaFiles: List() }); +const initialState = Map({ entry: Map(), mediaFiles: List(), fieldsMetaData: Map() }); const entryDraftReducer = (state = Map(), action) => { switch (action.type) { @@ -23,6 +23,7 @@ const entryDraftReducer = (state = Map(), action) => { state.set('entry', action.payload); state.setIn(['entry', 'newRecord'], false); state.set('mediaFiles', List()); + state.set('fieldsMetaData', Map()); }); case DRAFT_CREATE_EMPTY: // New Entry @@ -30,12 +31,15 @@ const entryDraftReducer = (state = Map(), action) => { state.set('entry', fromJS(action.payload)); state.setIn(['entry', 'newRecord'], true); state.set('mediaFiles', List()); + state.set('fieldsMetaData', Map()); }); case DRAFT_DISCARD: return initialState; - case DRAFT_CHANGE: - return state.set('entry', action.payload); - + case DRAFT_CHANGE_FIELD: + return state.withMutations((state) => { + state.setIn(['entry', 'data', action.payload.field], action.payload.value); + state.mergeIn(['fieldsMetaData'], fromJS(action.payload.metadata)); + }); case ENTRY_PERSIST_REQUEST: { return state.setIn(['entry', 'isPersisting'], true); } diff --git a/src/reducers/search.js b/src/reducers/search.js index ae37d366..4bbc77c1 100644 --- a/src/reducers/search.js +++ b/src/reducers/search.js @@ -44,7 +44,7 @@ const entries = (state = defaultState, action) => { case QUERY_REQUEST: if (action.payload.searchTerm !== state.get('term')) { return state.withMutations((map) => { - map.set('isFetching', true); + map.set('isFetching', action.payload.namespace); map.set('term', action.payload.searchTerm); }); } @@ -56,7 +56,7 @@ const entries = (state = defaultState, action) => { return state.withMutations((map) => { map.set('isFetching', false); map.set('term', searchTerm); - map.set('queryHits', response.hits); + map.set('queryHits', Map({ [action.payload.namespace]: response.hits })); }); default: