diff --git a/src/client/components/pages/help.js b/src/client/components/pages/help.js index 5251b5c3f5..f8a5ebcef0 100644 --- a/src/client/components/pages/help.js +++ b/src/client/components/pages/help.js @@ -79,8 +79,8 @@ function HelpPage() { - {genEntityIconHTMLElement('EditionGroup')}Edition Group – a logical grouping of similar Editions. - + {genEntityIconHTMLElement('EditionGroup')}Edition Group – a logical grouping of different Editions of the same book. + {genEntityIconHTMLElement('Publisher')}Publisher – publishing company or imprint @@ -111,11 +111,15 @@ function HelpPage() {
  1. Find or add a new {genEntityIconHTMLElement('Author')}Author
  2. -
  3. Find or add a new {genEntityIconHTMLElement('Work')}Work with an 'Author wrote Work' relationship
  4. -
  5. Find or add a new {genEntityIconHTMLElement('Publisher')}Publisher
  6. -
  7. Find or add a new {genEntityIconHTMLElement('EditionGroup')}Edition Group
  8. -
  9. Find or add a new {genEntityIconHTMLElement('Edition')}Edition with a 'contains Work' relationship and fill in the Publisher
  10. -
  11. If another format of the edition from the same publisher exists (see below), go to the Edition Group and click the 'Add Edition' button. Repeat step 5.
  12. +
  13. On the Author page, click on 'Add Work' to create a {genEntityIconHTMLElement('Work')}Work with a relationship to the Author
  14. +
  15. On the Work page, click 'Add Edition' to create an {genEntityIconHTMLElement('Edition')}Edition with a relationship to the Work
  16. +
  17. + +
  18. +
  19. To enter another format of the same book (see explanations below), go to the Edition Group and click the 'Add Edition' button. Repeat step 4.

When should I create a new Edition of a Work?

@@ -137,12 +141,15 @@ function HelpPage() {

When should two Editions be part of the same Edition Group?

- Generally, when a publisher releases multiple different formats of the same content, meaning: + Edition Groups exist to group together all the variations of an edition (an identifiable set of works) in a given language. + Here are examples of Editions that should be part of the same Edition Group: +
diff --git a/src/client/entity-editor/common/entity-search-field-option.js b/src/client/entity-editor/common/entity-search-field-option.js index 4651d84075..e4cdfb5313 100644 --- a/src/client/entity-editor/common/entity-search-field-option.js +++ b/src/client/entity-editor/common/entity-search-field-option.js @@ -85,16 +85,21 @@ class EntitySearchFieldOption extends React.Component { }; } - fetchOptions(query) { - return request + async fetchOptions(query) { + if (!query) { + return { + options: [] + }; + } + const response = await request .get('/search/autocomplete') .query({ collection: this.props.type, q: query - }) - .then((response) => ({ - options: response.body.map(this.entityToOption) - })); + }); + return { + options: response.body.map(this.entityToOption) + }; } render() { diff --git a/src/client/entity-editor/edition-section/actions.js b/src/client/entity-editor/edition-section/actions.js index 3bcb0ec0f7..ab08a9e6f4 100644 --- a/src/client/entity-editor/edition-section/actions.js +++ b/src/client/entity-editor/edition-section/actions.js @@ -54,6 +54,7 @@ export const UPDATE_HEIGHT = 'UPDATE_HEIGHT'; export const UPDATE_DEPTH = 'UPDATE_DEPTH'; export const SHOW_PHYSICAL = 'SHOW_PHYSICAL'; export const SHOW_EDITION_GROUP = 'SHOW_EDITION_GROUP'; +export const UPDATE_WARN_IF_EDITION_GROUP_EXISTS = 'UPDATE_WARN_IF_EDITION_GROUP_EXISTS'; /** * Produces an action indicating that the edition status for the edition being diff --git a/src/client/entity-editor/edition-section/edition-section.js b/src/client/entity-editor/edition-section/edition-section.js index 9d1d682f77..16f3a50ac0 100644 --- a/src/client/entity-editor/edition-section/edition-section.js +++ b/src/client/entity-editor/edition-section/edition-section.js @@ -34,10 +34,12 @@ import { updatePublisher, updateStatus } from './actions'; -import {Button, Col, Row} from 'react-bootstrap'; + +import {Alert, Button, Col, Row} from 'react-bootstrap'; import type {List, Map} from 'immutable'; import { validateEditionSectionDepth, + validateEditionSectionEditionGroup, validateEditionSectionHeight, validateEditionSectionPages, validateEditionSectionReleaseDate, @@ -45,13 +47,14 @@ import { validateEditionSectionWidth } from '../validators/edition'; - import CustomInput from '../../input'; import DateField from '../common/new-date-field'; import EntitySearchFieldOption from '../common/entity-search-field-option'; +import Icon from 'react-fontawesome'; import LanguageField from '../common/language-field'; import NumericField from '../common/numeric-field'; import React from 'react'; +import SearchResults from '../../components/pages/parts/search-results'; import Select from 'react-select'; import _ from 'lodash'; import {connect} from 'react-redux'; @@ -100,8 +103,10 @@ type StateProps = { pagesValue: ?number, physicalVisible: ?boolean, publisherValue: Map, + editionGroupRequired: ?boolean, editionGroupVisible: ?boolean, editionGroupValue: Map, + matchingNameEditionGroups: ?array, releaseDateValue: ?object, statusValue: ?number, weightValue: ?number, @@ -169,8 +174,10 @@ function EditionSection({ onWidthChange, pagesValue, physicalVisible, + editionGroupRequired, editionGroupValue, editionGroupVisible, + matchingNameEditionGroups, publisherValue, releaseDateValue, statusValue, @@ -194,28 +201,85 @@ function EditionSection({ const {isValid: isValidReleaseDate, errorMessage: dateErrorMessage} = validateEditionSectionReleaseDate(releaseDateValue); - return ( -
-

- What else do you know about the Edition? -

-

- Edition Group is required — this cannot be blank. Click here to create one if you did not find an existing one. -

- + const hasmatchingNameEditionGroups = Array.isArray(matchingNameEditionGroups) && matchingNameEditionGroups.length > 0; + const getEditionGroupSearchSelect = () => ( + + + {hasmatchingNameEditionGroups && + + {matchingNameEditionGroups.length > 1 ? + 'Edition Groups with the same name as this Edition already exist' : + 'An existing Edition Group with the same name as this Edition already exists' + }: +
+ The first match has been selected automatically.
+ Please review the choice: click on an item to open it in a new tab: +
+ +
+ } + + +
+
+ ); + + const alertAutoCreateEditionGroup = + !editionGroupValue && + !editionGroupVisible && + !editionGroupRequired && + !hasmatchingNameEditionGroups; + + return ( + +

+ What else do you know about the Edition? +

+

+ Edition Group is required — this cannot be blank +

+ { + alertAutoCreateEditionGroup ? + + + + A new Edition Group with the same name will be created automatically. +
+ +
+ +
: + getEditionGroupSearchSelect() + }

Below fields are optional — leave something blank if you don’t know it @@ -280,22 +344,6 @@ function EditionSection({ - - { - !editionGroupVisible && - - - - - - } - { physicalVisible && @@ -366,14 +414,17 @@ EditionSection.displayName = 'EditionSection'; type RootState = Map>; function mapStateToProps(rootState: RootState): StateProps { const state: Map = rootState.get('editionSection'); + const matchingNameEditionGroups = state.get('matchingNameEditionGroups'); return { depthValue: state.get('depth'), + editionGroupRequired: state.get('editionGroupRequired'), editionGroupValue: state.get('editionGroup'), editionGroupVisible: state.get('editionGroupVisible'), formatValue: state.get('format'), heightValue: state.get('height'), languageValues: state.get('languages'), + matchingNameEditionGroups, pagesValue: state.get('pages'), physicalVisible: state.get('physicalVisible'), publisherValue: state.get('publisher'), diff --git a/src/client/entity-editor/edition-section/reducer.js b/src/client/entity-editor/edition-section/reducer.js index 1378b0e6f1..c0e537c3fc 100644 --- a/src/client/entity-editor/edition-section/reducer.js +++ b/src/client/entity-editor/edition-section/reducer.js @@ -19,6 +19,7 @@ // @flow import * as Immutable from 'immutable'; +import * as _ from 'lodash'; import { type Action, @@ -33,6 +34,7 @@ import { UPDATE_PUBLISHER, UPDATE_RELEASE_DATE, UPDATE_STATUS, + UPDATE_WARN_IF_EDITION_GROUP_EXISTS, UPDATE_WEIGHT, UPDATE_WIDTH } from './actions'; @@ -78,6 +80,18 @@ function reducer( return state.set('height', payload); case UPDATE_DEPTH: return state.set('depth', payload); + case UPDATE_WARN_IF_EDITION_GROUP_EXISTS: + if (!Array.isArray(payload) || !payload.length) { + return state.set('matchingNameEditionGroups', []); + } + return state.set('matchingNameEditionGroups', payload) + .set('editionGroup', Immutable.fromJS({ + disambiguation: _.get(payload[0], ['disambiguation', 'comment']), + id: payload[0].bbid, + text: _.get(payload[0], ['defaultAlias', 'name']), + type: payload[0].type, + value: payload[0].bbid + })); // no default } return state; diff --git a/src/client/entity-editor/name-section/actions.js b/src/client/entity-editor/name-section/actions.js index c5935a66b9..d4a90e673e 100644 --- a/src/client/entity-editor/name-section/actions.js +++ b/src/client/entity-editor/name-section/actions.js @@ -109,16 +109,25 @@ export function debouncedUpdateDisambiguationField( * * @param {string} name - The value to be checked if it already exists. * @param {string} entityType - The entity type of the value to be checked. + * @param {string} action - An optional redux action to dispatch. Defaults to UPDATE_WARN_IF_EXISTS * @returns {many_prompts~inner} The returned function. */ export function checkIfNameExists( name: string, - entityType: string + entityType: string, + action: ?string ): ((Action) => mixed) => mixed { /** * @param {function} dispatch - The redux dispatch function. */ return (dispatch) => { + if (!name) { + dispatch({ + payload: null, + type: action || UPDATE_WARN_IF_EXISTS + }); + return; + } request.get('/search/exists') .query({ collection: _snakeCase(entityType), @@ -126,7 +135,7 @@ export function checkIfNameExists( }) .then(res => dispatch({ payload: JSON.parse(res.text) || null, - type: UPDATE_WARN_IF_EXISTS + type: action || UPDATE_WARN_IF_EXISTS })) .catch((error: {message: string}) => error); }; @@ -148,6 +157,13 @@ export function searchName( * @param {function} dispatch - The redux dispatch function. */ return (dispatch) => { + if (!name) { + dispatch({ + payload: null, + type: UPDATE_SEARCH_RESULTS + }); + return; + } request.get('/search/autocomplete') .query({ collection: type, diff --git a/src/client/entity-editor/name-section/name-section.js b/src/client/entity-editor/name-section/name-section.js index ec4b79b8fe..e025ecb518 100644 --- a/src/client/entity-editor/name-section/name-section.js +++ b/src/client/entity-editor/name-section/name-section.js @@ -40,6 +40,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import SearchResults from '../../components/pages/parts/search-results'; import SortNameField from '../common/sort-name-field'; +import {UPDATE_WARN_IF_EDITION_GROUP_EXISTS} from '../edition-section/actions'; import _ from 'lodash'; import {connect} from 'react-redux'; import {entityTypeProperty} from '../../helpers/react-validators'; @@ -97,6 +98,12 @@ class NameSection extends React.Component { this.props.onNameChange(event.target.value); this.props.onNameChangeCheckIfExists(event.target.value); this.props.onNameChangeSearchName(event.target.value); + if ( + _.toLower(this.props.entityType) === 'edition' && + this.props.searchForExistingEditionGroup + ) { + this.props.onNameChangeCheckIfEditionGroupExists(event.target.value); + } } updateNameFieldInputRef(inputRef) { @@ -139,7 +146,8 @@ class NameSection extends React.Component { )} error={!validateNameSectionName(nameValue)} inputRef={this.updateNameFieldInputRef} - tooltipText={`Official name of the ${_.startCase(entityType)} in its original language. Names in other languages should be added as 'aliases'.`} + tooltipText={`Official name of the ${_.startCase(entityType)} in its original language. + Names in other languages should be added as aliases.`} warn={(isRequiredDisambiguationEmpty( warnIfExists, disambiguationDefaultValue @@ -252,9 +260,11 @@ NameSection.propTypes = { onDisambiguationChange: PropTypes.func.isRequired, onLanguageChange: PropTypes.func.isRequired, onNameChange: PropTypes.func.isRequired, + onNameChangeCheckIfEditionGroupExists: PropTypes.func.isRequired, onNameChangeCheckIfExists: PropTypes.func.isRequired, onNameChangeSearchName: PropTypes.func.isRequired, onSortNameChange: PropTypes.func.isRequired, + searchForExistingEditionGroup: PropTypes.bool, searchResults: PropTypes.array, sortNameValue: PropTypes.string.isRequired }; @@ -263,12 +273,19 @@ NameSection.defaultProps = { disambiguationDefaultValue: null, exactMatches: null, languageValue: null, + searchForExistingEditionGroup: true, searchResults: null }; function mapStateToProps(rootState) { const state = rootState.get('nameSection'); + const editionSectionState = rootState.get('editionSection'); + const searchForExistingEditionGroup = Boolean(editionSectionState) && + ( + !editionSectionState.get('editionGroup') || + editionSectionState.get('editionGroupRequired') + ); return { disambiguationDefaultValue: state.get('disambiguation'), disambiguationVisible: @@ -276,6 +293,7 @@ function mapStateToProps(rootState) { exactMatches: state.get('exactMatches'), languageValue: state.get('language'), nameValue: state.get('name'), + searchForExistingEditionGroup, searchResults: state.get('searchResults'), sortNameValue: state.get('sortName') }; @@ -289,6 +307,9 @@ function mapDispatchToProps(dispatch, {entityType}) { dispatch(updateLanguageField(value && value.value)), onNameChange: (value) => dispatch(debouncedUpdateNameField(value, entityType)), + onNameChangeCheckIfEditionGroupExists: _.debounce((value) => { + dispatch(checkIfNameExists(value, 'EditionGroup', UPDATE_WARN_IF_EDITION_GROUP_EXISTS)); + }, 1500), onNameChangeCheckIfExists: _.debounce((value) => { dispatch(checkIfNameExists(value, entityType)); }, 500), diff --git a/src/client/entity-editor/validators/edition.js b/src/client/entity-editor/validators/edition.js index 136d3fa9aa..3a44d9b80d 100644 --- a/src/client/entity-editor/validators/edition.js +++ b/src/client/entity-editor/validators/edition.js @@ -68,8 +68,8 @@ export function validateEditionSectionPages(value: ?any): boolean { return validatePositiveInteger(value); } -export function validateEditionSectionEditionGroup(value: ?any): boolean { - return validateUUID(get(value, 'id', null), true); +export function validateEditionSectionEditionGroup(value: ?any, editionGroupRequired: ?boolean): boolean { + return validateUUID(get(value, 'id', null), editionGroupRequired); } export function validateEditionSectionPublisher(value: ?any): boolean { @@ -104,7 +104,10 @@ export function validateEditionSection(data: any): boolean { validateEditionSectionHeight(get(data, 'height', null)) && validateEditionSectionLanguages(get(data, 'languages', null)) && validateEditionSectionPages(get(data, 'pages', null)) && - validateEditionSectionEditionGroup(get(data, 'editionGroup', null)) && + validateEditionSectionEditionGroup( + get(data, 'editionGroup', null), + get(data, 'editionGroupRequired', null) || get(data, 'matchingNameEditionGroups', []).length + ) && validateEditionSectionPublisher(get(data, 'publisher', null)) && validateEditionSectionReleaseDate(convertMapToObject(get(data, 'releaseDate', null))).isValid && validateEditionSectionStatus(get(data, 'status', null)) && diff --git a/src/server/helpers/search.js b/src/server/helpers/search.js index 7f12a76e54..d23540f05c 100644 --- a/src/server/helpers/search.js +++ b/src/server/helpers/search.js @@ -380,7 +380,7 @@ export async function checkIfExists(orm, name, collection) { ]; return Promise.all( bbids.map( - bbid => orm.func.entity.getEntity(orm, _.upperFirst(collection), bbid, baseRelations) + bbid => orm.func.entity.getEntity(orm, _.upperFirst(_.camelCase(collection)), bbid, baseRelations) ) ); } diff --git a/src/server/routes/entity/edition.js b/src/server/routes/entity/edition.js index c990f5f27b..68bfb204fc 100644 --- a/src/server/routes/entity/edition.js +++ b/src/server/routes/entity/edition.js @@ -249,8 +249,6 @@ function editionToFormState(edition) { _.isNull(edition.width) ); - const editionGroupVisible = !_.isNull(edition.editionGroup); - const releaseDate = edition.releaseEventSetId ? separateDateInObject(edition.releaseEventSet.releaseEvents[0].date) : {day: '', month: '', year: ''}; @@ -265,7 +263,8 @@ function editionToFormState(edition) { const editionSection = { depth: edition.depth, editionGroup, - editionGroupVisible, + editionGroupRequired: true, + editionGroupVisible: true, format: edition.editionFormat && edition.editionFormat.id, height: edition.height, languages: edition.languageSet ? edition.languageSet.languages.map( diff --git a/src/server/routes/entity/entity.js b/src/server/routes/entity/entity.js index 6fd9d109e5..0f17f933b6 100644 --- a/src/server/routes/entity/entity.js +++ b/src/server/routes/entity/entity.js @@ -647,7 +647,7 @@ export function handleCreateOrEditEntity( derivedProps: {} ) { const {orm}: {orm: any} = req.app.locals; - const {Revision, bookshelf} = orm; + const {Entity, Revision, bookshelf} = orm; const editorJSON = req.user; const {body}: {body: any} = req; @@ -667,7 +667,7 @@ export function handleCreateOrEditEntity( const isNew = !currentEntity; if (isNew) { - const newEntity = await new orm.Entity({type: entityType}) + const newEntity = await new Entity({type: entityType}) .save(null, {transacting}); const newEntityBBID = newEntity.get('bbid'); body.relationships = _.map( diff --git a/test/src/client/entity-editor/create-edition.js b/test/src/client/entity-editor/create-edition.js new file mode 100644 index 0000000000..471b2c32d0 --- /dev/null +++ b/test/src/client/entity-editor/create-edition.js @@ -0,0 +1,23 @@ +import {createEdition, getRandomUUID, truncateEntities} from '../../../test-helpers/create-entities'; +import chai from 'chai'; +import orm from '../../../bookbrainz-data'; + + +const {expect} = chai; +const { + bookshelf, util, Edition +} = orm; + +describe('Creating an Edition', () => { + const bbid = getRandomUUID(); + + beforeEach(() => createEdition(bbid)); + afterEach(truncateEntities); + + it('should automatically create an Edition Group if none is passed', async () => { + const edition = await Edition.forge({bbid}).fetch({withRelated: ['editionGroup']}); + const editionJson = edition.toJSON(); + expect(editionJson.bbid).to.equal(bbid); + expect(editionJson.editionGroupBbid).to.be.a('string'); + }); +}); diff --git a/test/src/client/entity-editor/validators/test-edition.js b/test/src/client/entity-editor/validators/test-edition.js index e5a62ad3db..3c632db865 100644 --- a/test/src/client/entity-editor/validators/test-edition.js +++ b/test/src/client/entity-editor/validators/test-edition.js @@ -204,6 +204,16 @@ function describevalidateEditionSectionEditionGroup() { expect(result).to.be.true; }); + it('should pass a null value', () => { + const result = validateEditionSectionEditionGroup(null); + expect(result).to.be.true; + }); + + it('should pass any other non-null data type with no ID', () => { + const result = validateEditionSectionEditionGroup(1); + expect(result).to.be.true; + }); + it('should reject an Object with an invalid ID', () => { const result = validateEditionSectionEditionGroup( {...VALID_ENTITY, id: '2'} @@ -217,16 +227,6 @@ function describevalidateEditionSectionEditionGroup() { ); expect(result).to.be.false; }); - - it('should reject any other non-null data type', () => { - const result = validateEditionSectionEditionGroup(1); - expect(result).to.be.false; - }); - - it('should reject a null value', () => { - const result = validateEditionSectionEditionGroup(null); - expect(result).to.be.false; - }); } function describeValidateEditionSectionPublisher() { @@ -295,6 +295,16 @@ function describeValidateEditionSection() { expect(result).to.be.true; }); + it('should pass a null value', () => { + const result = validateEditionSection(null); + expect(result).to.be.true; + }); + + it('should ignore any other non-null data type', () => { + const result = validateEditionSection(1); + expect(result).to.be.true; + }); + it('should reject an Object with an invalid depth', () => { const result = validateEditionSection({ ...VALID_EDITION_SECTION, @@ -389,16 +399,6 @@ function describeValidateEditionSection() { ); expect(result).to.be.false; }); - - it('should reject any other non-null data type', () => { - const result = validateEditionSection(1); - expect(result).to.be.false; - }); - - it('should reject a null value', () => { - const result = validateEditionSection(null); - expect(result).to.be.false; - }); }