diff --git a/__tests__/components/editor/RDFModal.test.js b/__tests__/components/editor/RDFModal.test.js index b7ae2abac..a5af54421 100644 --- a/__tests__/components/editor/RDFModal.test.js +++ b/__tests__/components/editor/RDFModal.test.js @@ -1,10 +1,10 @@ -// Copyright 2018 Stanford University see LICENSE for license +// Copyright 2019 Stanford University see LICENSE for license import React from 'react' import Modal from 'react-bootstrap/lib/Modal' import Button from 'react-bootstrap/lib/Button' import { shallow } from 'enzyme' -import RDFModal from '../../../src/components/editor/RDFModal' +import RDFModal from 'components/editor/RDFModal' describe('', () => { const closeFunc = jest.fn() diff --git a/__tests__/components/editor/property/InputURI.test.js b/__tests__/components/editor/property/InputURI.test.js new file mode 100644 index 000000000..84cb522d8 --- /dev/null +++ b/__tests__/components/editor/property/InputURI.test.js @@ -0,0 +1,227 @@ +// Copyright 2019 Stanford University see LICENSE for license + +import React from 'react' +import { shallow } from 'enzyme' +import shortid from 'shortid' +import InputURI from 'components/editor/property/InputURI' + +const plProps = { + propertyTemplate: { + propertyLabel: 'Has Equivalent', + propertyURI: 'http://id.loc.gov/ontologies/bibframe/hasEquivalent', + type: 'resource', + mandatory: '', + repeatable: '', + }, + reduxPath: [ + 'resourceTemplate:bf2:Monograph:Instance', + 'http://id.loc.gov/ontologies/bibframe/hasEquivalent', + ], + formData: { + items: [], + errors: [], + }, +} + + +describe('', () => { + const wrapper = shallow() + + it('contains a placeholder', () => { + expect(wrapper.find('input').props().placeholder).toBe('Has Equivalent') + }) + + it('contains required="true" attribute on input tag when mandatory is true', () => { + const propertyTemplate = { propertyTemplate: { ...plProps.propertyTemplate, mandatory: 'true' } } + const formData = { formData: { errors: [{ id: 'Required' }] } } + wrapper.setProps({ ...plProps, ...propertyTemplate, ...formData }) + expect(wrapper.find('input').prop('required')).toBeTruthy() + expect(wrapper.find('label > RequiredSuperscript')).toBeTruthy() + }) + + it('contains required="false" attribute on input tag when mandatory is false', () => { + const propertyTemplate = { propertyTemplate: { ...plProps.propertyTemplate, mandatory: 'false' } } + wrapper.setProps({ ...plProps, ...propertyTemplate }) + expect(wrapper.find('input').prop('required')).toBeFalsy() + }) + + it('label contains a PropertyRemark when a remark is added', () => { + const propertyTemplate = { propertyTemplate: { ...plProps.propertyTemplate, remark: 'http://rda.test.org/1.1' } } + wrapper.setProps({ ...plProps, ...propertyTemplate }) + const propertyRemark = wrapper.find('label > PropertyRemark') + + expect(propertyRemark).toBeTruthy() + }) +}) + +describe('When the user enters input into field', () => { + // Our mockItemsChange function to replace the one provided by mapDispatchToProps + let mockItemsChange + let removeMockDataFn + let mockWrapper + + shortid.generate = jest.fn().mockReturnValue(0) + + beforeEach(() => { + mockItemsChange = jest.fn() + removeMockDataFn = jest.fn() + + mockWrapper = shallow() + }) + + it('has an id value as a unique property', () => { + expect(mockWrapper.find('input').prop('id')).toEqual('11') + }) + + it('calls handleMyItemsChange function', () => { + mockWrapper.find('input').simulate('change', { target: { value: 'http://example.com/thing/1' } }) + mockWrapper.find('input').simulate('keypress', { key: 'Enter', preventDefault: () => {} }) + expect(mockItemsChange).toHaveBeenCalled() + }) + + it('doesn\'t accept invalid URIs', () => { + mockWrapper.find('input').simulate('change', { target: { value: 'Not a URI' } }) + mockWrapper.find('input').simulate('keypress', { key: 'Enter', preventDefault: () => {} }) + expect(mockItemsChange).not.toHaveBeenCalled() + }) + + it('is called with the users input as arguments', () => { + const propertyTemplate = { propertyTemplate: { ...plProps.propertyTemplate, repeatable: 'false' } } + mockWrapper.setProps({ ...plProps, ...propertyTemplate }) + mockWrapper.find('input').simulate('change', { target: { value: 'http://example.com/thing/1' } }) + mockWrapper.find('input').simulate('keypress', { key: 'Enter', preventDefault: () => {} }) + // Test to see arguments used after it's been submitted + + expect(mockItemsChange.mock.calls[0][0]).toEqual( + { + uri: 'http://id.loc.gov/ontologies/bibframe/hasEquivalent', + items: [{ uri: 'http://example.com/thing/1', id: 0 }], + reduxPath: ['resourceTemplate:bf2:Monograph:Instance', 'http://id.loc.gov/ontologies/bibframe/hasEquivalent'], + }, + ) + }) + + it('property template contains repeatable "true", allowed to add more than one item into myItems array', () => { + const propertyTemplate = { propertyTemplate: { ...plProps.propertyTemplate, repeatable: 'true' } } + mockWrapper.setProps({ ...plProps, ...propertyTemplate }) + mockWrapper.find('input').simulate('change', { target: { value: 'http://example.com/thing/1' } }) + mockWrapper.find('input').simulate('keypress', { key: 'Enter', preventDefault: () => {} }) + mockWrapper.find('input').simulate('change', { target: { value: 'http://example.com/thing/2' } }) + mockWrapper.find('input').simulate('keypress', { key: 'Enter', preventDefault: () => {} }) + + expect(mockItemsChange.mock.calls[0][0]).toEqual( + { + uri: 'http://id.loc.gov/ontologies/bibframe/hasEquivalent', + items: [{ uri: 'http://example.com/thing/1', id: 0 }], + reduxPath: ['resourceTemplate:bf2:Monograph:Instance', 'http://id.loc.gov/ontologies/bibframe/hasEquivalent'], + }, + ) + expect(mockItemsChange.mock.calls[1][0]).toEqual( + { + uri: 'http://id.loc.gov/ontologies/bibframe/hasEquivalent', + items: [{ uri: 'http://example.com/thing/2', id: 0 }], + reduxPath: ['resourceTemplate:bf2:Monograph:Instance', 'http://id.loc.gov/ontologies/bibframe/hasEquivalent'], + }, + ) + mockItemsChange.mock.calls = [] // Reset the redux store to empty + }) + + it('item appears when user inputs text into the field', () => { + const propertyTemplate = { propertyTemplate: { ...plProps.propertyTemplate, repeatable: 'false' } } + mockWrapper.setProps({ + ...plProps, + ...propertyTemplate, + formData: { id: 1, uri: 'http://id.loc.gov/ontologies/bibframe/hasEquivalent' }, + items: [{ uri: 'http://example.com/thing/1', id: 4 }], + }) + expect(mockWrapper.find('div#userInput').text()).toEqual('http://example.com/thing/1XEdit') // Contains X and Edit as buttons + }) + + it('calls the removeMockDataFn when X is clicked', () => { + mockWrapper.setProps({ + formData: { id: 1, uri: 'http://id.loc.gov/ontologies/bibframe/hasEquivalent' }, + items: [{ uri: 'test' }], + }) + expect(removeMockDataFn.mock.calls.length).toEqual(0) + mockWrapper.find('button#deleteItem').first().simulate('click', { target: { dataset: { item: 5 } } }) + expect(removeMockDataFn.mock.calls.length).toEqual(1) + }) +}) + +describe('when there is a default literal value in the property template', () => { + const mockMyItemsChange = jest.fn() + const mockRemoveItem = jest.fn() + + it('sets the default values according to the property template if they exist', () => { + const plProps = { + propertyTemplate: { + propertyLabel: 'Instance of', + propertyURI: 'http://id.loc.gov/ontologies/bibframe/hasEquivalent', + type: 'literal', + mandatory: '', + repeatable: '', + valueConstraint: { + valueTemplateRefs: [], + useValuesFrom: [], + valueDataType: {}, + defaults: [{ + defaultURI: 'http://id.loc.gov/vocabulary/organizations/dlc', + defaultLiteral: 'DLC', + }, + ], + }, + }, + formData: { + errors: [], + }, + items: [ + { + uri: 'http://id.loc.gov/vocabulary/organizations/dlc', + }, + ], + } + const wrapper = shallow() + + expect(wrapper.find('#userInput').text()).toMatch('http://id.loc.gov/vocabulary/organizations/dlc') + }) + + describe('when repeatable="false"', () => { + const nrProps = { + propertyTemplate: + { + propertyLabel: 'Instance of', + propertyURI: 'http://id.loc.gov/ontologies/bibframe/hasEquivalent', + type: 'literal', + mandatory: '', + repeatable: 'false', + }, + formData: {}, + } + + it('input has disabled attribute when there are items', () => { + const nonrepeatWrapper = shallow( + , + ) + + expect(nonrepeatWrapper.exists('input', { disabled: true })).toBe(true) + }) + + it('input does not have disabled attribute when there are no items', () => { + const nonrepeatWrapper = shallow( + , + ) + expect(nonrepeatWrapper.exists('input', { disabled: false })).toBe(true) + }) + }) +}) diff --git a/__tests__/components/editor/property/PropertyComponent.test.js b/__tests__/components/editor/property/PropertyComponent.test.js index b0736f532..afcb26f2a 100644 --- a/__tests__/components/editor/property/PropertyComponent.test.js +++ b/__tests__/components/editor/property/PropertyComponent.test.js @@ -62,6 +62,19 @@ describe('', () => { }) }) + describe('for templates configured as resource', () => { + const template = { + propertyURI: 'http://id.loc.gov/ontologies/bibframe/hasEquivalent', + type: 'resource', + } + + const wrapper = shallow() + + it('renders the uri component', () => { + expect(wrapper.find('Connect(InputURI)').length).toEqual(1) + }) + }) + describe('when there are no configuration values from the property template', () => { describe('for unconfigured templates of type:literal', () => { const template = { diff --git a/src/components/editor/property/InputURI.jsx b/src/components/editor/property/InputURI.jsx new file mode 100644 index 000000000..f7ec8930d --- /dev/null +++ b/src/components/editor/property/InputURI.jsx @@ -0,0 +1,195 @@ +// Copyright 2019 Stanford University see LICENSE for license + +import React, { useRef, useState } from 'react' +import PropTypes from 'prop-types' +import { connect } from 'react-redux' +import shortid from 'shortid' +import { removeItem, setItems } from 'actions/index' +import { findNode, getDisplayValidations, getPropertyTemplate } from 'selectors/resourceSelectors' +import { booleanPropertyFromTemplate } from 'Utilities' + +const InputURI = (props) => { + // Don't render if don't have property templates yet. + if (!props.propertyTemplate) { + return null + } + + const inputLiteralRef = useRef(Math.floor(100 * Math.random())) + const [content, setContent] = useState('') + + const disabled = !booleanPropertyFromTemplate(props.propertyTemplate, 'repeatable', true) + && props.items?.length > 0 + + const handleFocus = (event) => { + document.getElementById(event.target.id).focus() + event.preventDefault() + } + + const isValidURI = (value) => { + try { + /* eslint no-new: 'off' */ + new URL(value) + return true + } catch (e) { + return false + } + } + + const addItem = () => { + const currentcontent = content.trim() + + if (!currentcontent || !isValidURI(currentcontent)) { + return + } + + const userInput = { + uri: props.propertyTemplate.propertyURI, + reduxPath: props.reduxPath, + items: [{ uri: currentcontent, id: shortid.generate() }], + } + + props.handleMyItemsChange(userInput) + setContent('') + } + + const handleKeypress = (event) => { + if (event.key === 'Enter') { + addItem() + event.preventDefault() + } + } + + const handleDeleteClick = (event) => { + props.handleRemoveItem(props.reduxPath, event.target.dataset.item) + } + + const handleEditClick = (event) => { + const idToRemove = event.target.dataset.item + + props.items.forEach((item) => { + if (item.id === idToRemove) { + const itemContent = item.uri + + setContent(itemContent) + } + }) + + handleDeleteClick(event) + inputLiteralRef.current.focus() + } + + /** + * @return {bool} true if the field should be marked as required (e.g. not all obligations met) + */ + const required = booleanPropertyFromTemplate(props.propertyTemplate, 'mandatory', false) + && props.formData.errors + && props.formData.errors.length !== 0 + + const items = props.items || [] + + const addedList = items.map((obj) => { + const itemId = obj.id || shortid.generate() + + return
+ {obj.uri} + + +
+ }) + + const error = props.displayValidations && required ? 'Required' : undefined + let groupClasses = 'form-group' + + if (error) { + groupClasses += ' has-error' + } + + return ( +
+ setContent(event.target.value)} + onKeyPress={handleKeypress} + value={content} + disabled={disabled} + id={props.id} + onClick={handleFocus} + ref={inputLiteralRef} + /> + {error && {error}} + {addedList} +
+ ) +} + +InputURI.propTypes = { + id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + propertyTemplate: PropTypes.shape({ + propertyLabel: PropTypes.string.isRequired, + propertyURI: PropTypes.string.isRequired, + mandatory: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), + repeatable: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), + remark: PropTypes.string, + valueConstraint: PropTypes.shape({ + defaults: PropTypes.array, + }), + }), + formData: PropTypes.shape({ + id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + uri: PropTypes.string, + errors: PropTypes.array, + }), + items: PropTypes.array, + handleMyItemsChange: PropTypes.func, + handleRemoveItem: PropTypes.func, + reduxPath: PropTypes.oneOfType([PropTypes.string, PropTypes.array]), + displayValidations: PropTypes.bool, +} + +const mapStateToProps = (state, props) => { + const reduxPath = props.reduxPath + const resourceTemplateId = reduxPath[reduxPath.length - 2] + const propertyURI = reduxPath[reduxPath.length - 1] + const displayValidations = getDisplayValidations(state) + const formData = findNode(state.selectorReducer, reduxPath) + // items has to be its own prop or rerendering won't occur when one is removed + const items = formData.items + const propertyTemplate = getPropertyTemplate(state, resourceTemplateId, propertyURI) + + return { + formData, + items, + reduxPath, + propertyTemplate, + displayValidations, + } +} + +const mapDispatchToProps = dispatch => ({ + handleMyItemsChange(userInput) { + dispatch(setItems(userInput)) + }, + handleRemoveItem(reduxPath, itemId) { + dispatch(removeItem(reduxPath, itemId)) + }, +}) + +export default connect(mapStateToProps, mapDispatchToProps)(InputURI) diff --git a/src/components/editor/property/PropertyComponent.jsx b/src/components/editor/property/PropertyComponent.jsx index 007084231..a9b1b97be 100644 --- a/src/components/editor/property/PropertyComponent.jsx +++ b/src/components/editor/property/PropertyComponent.jsx @@ -6,6 +6,8 @@ import PropTypes from 'prop-types' import InputLiteral from './InputLiteral' import InputListLOC from './InputListLOC' import InputLookupQA from './InputLookupQA' +import InputURI from './InputURI' + import { getLookupConfigItems } from 'Utilities' export class PropertyComponent extends Component { @@ -41,9 +43,15 @@ export class PropertyComponent extends Component { propertyTemplate = {property} lookupConfig = {this.state.configuration[0]} />) default: - if (property.type === 'literal') { - return () + switch (property.type) { + case 'literal': + return () + case 'resource': + return () + default: + console.error(`Unknown property type (component=${config}, type=${property.type})`) } } diff --git a/src/reducers/inputs.js b/src/reducers/inputs.js index 61750e2f9..4b3862505 100644 --- a/src/reducers/inputs.js +++ b/src/reducers/inputs.js @@ -77,7 +77,6 @@ export const setItemsOrSelections = (state, action) => { const newState = { ...state } const reduxPath = action.payload.reduxPath let level = 0 - reduxPath.reduce((obj, key) => { level++ // we've reached the end of the reduxPath, so set the items with the user input diff --git a/src/sinopiaServerSpoof.js b/src/sinopiaServerSpoof.js index f77d3442f..f1844e3f8 100644 --- a/src/sinopiaServerSpoof.js +++ b/src/sinopiaServerSpoof.js @@ -1,5 +1,6 @@ // Copyright 2018 Stanford University see LICENSE for license +const onlyEquivalentRt = require('../static/spoofedFilesFromServer/fromSinopiaServer/resourceTemplates/OnlyEquivalent.json') const barcodeRt = require('../static/spoofedFilesFromServer/fromSinopiaServer/resourceTemplates/Barcode.json') const monographInstanceRt = require('../static/spoofedFilesFromServer/fromSinopiaServer/resourceTemplates/MonographInstance.json') const monographWorkRt = require('../static/spoofedFilesFromServer/fromSinopiaServer/resourceTemplates/MonographWork.json') @@ -28,6 +29,7 @@ export const resourceTemplateId2Json = [ { id: 'resourceTemplate:bf2:Monograph:Instance', json: monographInstanceRt }, { id: 'resourceTemplate:bf2:Monograph:Work', json: monographWorkRt }, { id: 'resourceTemplate:bf2:Identifiers:Barcode', json: barcodeRt }, + { id: 'resourceTemplate:bf2:OnlyEquivalent', json: onlyEquivalentRt }, { id: 'resourceTemplate:bf2:Note', json: noteRt }, { id: 'resourceTemplate:bf2:ParallelTitle', json: parallelTitleRt }, { id: 'resourceTemplate:bf2:Title', json: titleRt }, diff --git a/static/spoofedFilesFromServer/fromSinopiaServer/resourceTemplates/Item.json b/static/spoofedFilesFromServer/fromSinopiaServer/resourceTemplates/Item.json index 36e475130..37176251a 100644 --- a/static/spoofedFilesFromServer/fromSinopiaServer/resourceTemplates/Item.json +++ b/static/spoofedFilesFromServer/fromSinopiaServer/resourceTemplates/Item.json @@ -48,6 +48,19 @@ "propertyURI": "http://id.loc.gov/ontologies/bibframe/enumerationAndChronology", "propertyLabel": "Enumeration and chronology" }, + { + "mandatory": "false", + "repeatable": "true", + "type": "resource", + "resourceTemplates": [], + "valueConstraint": { + "valueTemplateRefs": [], + "useValuesFrom": [], + "valueDataType": {} + }, + "propertyURI": "http://id.loc.gov/ontologies/bibframe/hasEquivalent", + "propertyLabel": "Equivalent" + }, { "mandatory": "true", "repeatable": "true", @@ -132,4 +145,4 @@ "remark": "http://access.rdatoolkit.org/rdachp4_rda4-93.html" } ] -} \ No newline at end of file +} diff --git a/static/spoofedFilesFromServer/fromSinopiaServer/resourceTemplates/OnlyEquivalent.json b/static/spoofedFilesFromServer/fromSinopiaServer/resourceTemplates/OnlyEquivalent.json new file mode 100644 index 000000000..7e909e780 --- /dev/null +++ b/static/spoofedFilesFromServer/fromSinopiaServer/resourceTemplates/OnlyEquivalent.json @@ -0,0 +1,20 @@ +{ + "resourceURI": "http://id.loc.gov/ontologies/bibframe/Item", + "resourceLabel": "Only the Equivalent", + "id": "resourceTemplate:bf2:OnlyEquivalent", + "propertyTemplates": [ + { + "mandatory": "false", + "repeatable": "true", + "type": "resource", + "resourceTemplates": [], + "valueConstraint": { + "valueTemplateRefs": [], + "useValuesFrom": [], + "valueDataType": {} + }, + "propertyURI": "http://id.loc.gov/ontologies/bibframe/hasEquivalent", + "propertyLabel": "Equivalent" + } + ] +}