diff --git a/CHANGELOG.md b/CHANGELOG.md index a6b7b84ff..0d77b9a20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +* 0.1.2 + ** MAJOR CHANGES ** + + - Add new selection field to sort filtered resources. Currently support to search by name, type, premise, people. + - Temporarily only show warning messages in 3 languages for IE11 user. + - Ability to favourite resources straight on search view instead going to resource detail page. + + ** CHANGELOG ** + + - [#895](https://github.com/City-of-Helsinki/varaamo/pull/895) Add sort to sort filtered resources. + - [#904](https://github.com/City-of-Helsinki/varaamo/pull/904) Favourite Resource on search view. + - [#909](https://github.com/City-of-Helsinki/varaamo/pull/909) Show warning message for IE11 users. + * 0.1.1 ** HOTFIX ** @@ -13,70 +26,70 @@ ** CHANGELOG ** UI changes: - - #863: Make homepage banner clickable. + - [#863](https://github.com/City-of-Helsinki/varaamo/pull/863): Make homepage banner clickable. - - #865: Delayed reservation. + - [#865](https://github.com/City-of-Helsinki/varaamo/pull/865): Delayed reservation. - - #867: Add config to fetch all unit that doesn't have empty resources. + - [#867](https://github.com/City-of-Helsinki/varaamo/pull/867): Add config to fetch all unit that doesn't have empty resources. - - #868: Add municipality filters for filtering resources base on municiples. + - [#868](https://github.com/City-of-Helsinki/varaamo/pull/868): Add municipality filters for filtering resources base on municiples. - - #873: Limit the selection of time slots to the ones within max period. + - [#873](https://github.com/City-of-Helsinki/varaamo/pull/873): Limit the selection of time slots to the ones within max period. - - #875: Expand advanced search panel when filters are applied. + - [#875](https://github.com/City-of-Helsinki/varaamo/pull/875): Expand advanced search panel when filters are applied. - - #876: Free-of-charge filter for resources. + - [#876](https://github.com/City-of-Helsinki/varaamo/pull/876): Free-of-charge filter for resources. - - #878: Remove the link for old website from the footer. + - [#878](https://github.com/City-of-Helsinki/varaamo/pull/878): Remove the link for old website from the footer. - - #883: Clear all filters after reset. + - [#883](https://github.com/City-of-Helsinki/varaamo/pull/883): Clear all filters after reset. - - #889: Disable reservation time limit for admins. + - [#889](https://github.com/City-of-Helsinki/varaamo/pull/889): Disable reservation time limit for admins. - - #899: Add unpublished tag to resource search list. + - [#899](https://github.com/City-of-Helsinki/varaamo/pull/899): Add unpublished tag to resource search list. - - #901: Add navigation links to staff and higher permission user. + - [#901](https://github.com/City-of-Helsinki/varaamo/pull/901): Add navigation links to staff and higher permission user. Upgrading: - - #854: Upgrade react-router to react-router v4. + - [#854](https://github.com/City-of-Helsinki/varaamo/pull/854): Upgrade react-router to react-router v4. - - #856: Add dockerize config to dockerize development environment. + - [#856](https://github.com/City-of-Helsinki/varaamo/pull/856): Add dockerize config to dockerize development environment. - - #857: Upgrade moment, moment-range, moment-timezome. + - [#857](https://github.com/City-of-Helsinki/varaamo/pull/857): Upgrade moment, moment-range, moment-timezome. - - #860: Upgrade lodash. + - [#860](https://github.com/City-of-Helsinki/varaamo/pull/860): Upgrade lodash. - - #862: Replace redux-logger with redux-devtools. + - [#862](https://github.com/City-of-Helsinki/varaamo/pull/862): Replace redux-logger with redux-devtools. - - #868: Upgrade react-select. + - [#868](https://github.com/City-of-Helsinki/varaamo/pull/868): Upgrade react-select. - - #874: Replace React internal prop-types with npm prop-types. + - [#874](https://github.com/City-of-Helsinki/varaamo/pull/874): Replace React internal prop-types with npm prop-types. - - #879: Upgrade React to 15.6.2, Enzyme to v3+. + - [#879](https://github.com/City-of-Helsinki/varaamo/pull/879): Upgrade React to 15.6.2, Enzyme to v3+. - - #882: Upgrade react-day-picker, remove react-date-picker. + - [#882](https://github.com/City-of-Helsinki/varaamo/pull/882): Upgrade react-day-picker, remove react-date-picker. - - #884: Upgrade babel to v7, webpack v4, replace Karma/Mocha/Chai with Jest. + - [#884](https://github.com/City-of-Helsinki/varaamo/pull/884): Upgrade babel to v7, webpack v4, replace Karma/Mocha/Chai with Jest. - - #890: Replace Chai with Jest's assertions. + - [#890](https://github.com/City-of-Helsinki/varaamo/pull/890): Replace Chai with Jest's assertions. - - #892: Remove unnecessary outdated dependencies: + - [#892](https://github.com/City-of-Helsinki/varaamo/pull/892): Remove unnecessary outdated dependencies: - Remove react-document-title, use react-helmet - Remove react-body-classname, classname append can be handled by classnames - - #893: Remove unnessary persisted state library, upgrade redux and dependencies: + - [#893](https://github.com/City-of-Helsinki/varaamo/pull/893): Remove unnessary persisted state library, upgrade redux and dependencies: - Remove redux-localstorage and redux-localstorage-filter. Replaced with vanilla code. - Upgrade redux and dependencies. - - #894: Upgrade React to 16.8.x: + - [#894](https://github.com/City-of-Helsinki/varaamo/pull/894): Upgrade React to 16.8.x: - Upgrade React to 16.8.x - Upgrade React's dependencies to latest. - - #900: Clean up obsolete/deprecated component. + - [#900](https://github.com/City-of-Helsinki/varaamo/pull/900): Clean up obsolete/deprecated component. - Delete navbar, sidebar, side-navbar which was replaced by new component but not getting removed. diff --git a/app/assets/icons/heart-filled.svg b/app/assets/icons/heart-filled.svg new file mode 100644 index 000000000..ec29d0177 --- /dev/null +++ b/app/assets/icons/heart-filled.svg @@ -0,0 +1,10 @@ + + + +heart-o + + diff --git a/app/constants/AppConstants.js b/app/constants/AppConstants.js index caed2752f..d8c11f3ea 100644 --- a/app/constants/AppConstants.js +++ b/app/constants/AppConstants.js @@ -57,6 +57,7 @@ export default { end: '', lat: '', lon: '', + orderBy: '', page: 1, people: '', purpose: '', @@ -68,4 +69,11 @@ export default { TIME_FORMAT: 'H:mm', TIME_SLOT_DEFAULT_LENGTH: 30, TRACKING: SETTINGS.TRACKING, + SORT_BY_OPTIONS: { + NAME: 'resource_name_lang', + TYPE: 'type_name_lang', + PREMISES: 'unit_name_lang', + PEOPLE: 'people_capacity', + // TODO: sortby 'open now' should be implemented later after API support it + } }; diff --git a/app/i18n/messages/en.json b/app/i18n/messages/en.json index 47bffbe89..9f505c9a3 100644 --- a/app/i18n/messages/en.json +++ b/app/i18n/messages/en.json @@ -257,6 +257,11 @@ "SearchControlsContainer.unitLabel": "Premise", "SearchPage.title": "Search", "ShowResourcesLink.text": "Show all premises and equipment", + "SortBy.label": "Sort By:", + "SortBy.name.label": "Name", + "SortBy.type.label": "Type", + "SortBy.premise.label": "Premise", + "SortBy.people.label": "People", "TestSiteMessage.text": "This is the test version of Varaamo", "TimeRangeControl.timeRangeTitle": "Time range and minimum duration", "TimeRangeControl.title": "{date} at {start}-{end} {hours}h booking", @@ -279,4 +284,4 @@ "UserReservationsPage.regularEmptyMessage": "No standard reservations", "UserReservationsPage.regularReservationsHeader": "Standard reservations", "UserReservationsPage.title": "My reservations" -} \ No newline at end of file +} diff --git a/app/i18n/messages/fi.json b/app/i18n/messages/fi.json index e5daed2bc..34a6981eb 100644 --- a/app/i18n/messages/fi.json +++ b/app/i18n/messages/fi.json @@ -257,6 +257,11 @@ "SearchControlsContainer.unitLabel": "Toimipiste", "SearchPage.title": "Haku", "ShowResourcesLink.text": "Näytä kaikki tilat ja laitteet", + "SortBy.label": "Järjestä:", + "SortBy.name.label": "Nimi", + "SortBy.type.label": "Tyyppi", + "SortBy.premise.label": "Toimipiste", + "SortBy.people.label": "Henkilömäärä", "TimeRangeControl.timeRangeTitle": "Käytä aikaväliä ja varauksen minimipituutta", "TimeRangeControl.title": "{date} klo {start}-{end} {hours}h varaus", "TestSiteMessage.text": "Tämä on Varaamon testiversio", @@ -279,4 +284,4 @@ "UserReservationsPage.regularEmptyMessage": "Ei tavallisia varauksia näytettäväksi.", "UserReservationsPage.regularReservationsHeader": "Tavalliset varaukset", "UserReservationsPage.title": "Omat varaukset" -} \ No newline at end of file +} diff --git a/app/i18n/messages/sv.json b/app/i18n/messages/sv.json index a719e0f34..5e018021c 100644 --- a/app/i18n/messages/sv.json +++ b/app/i18n/messages/sv.json @@ -259,6 +259,11 @@ "SearchControlsContainer.unitLabel": "Utrymmet", "SearchPage.title": "Sök", "ShowResourcesLink.text": "Visa alla utrymmen och apparater", + "SortBy.label": "Sortera efter:", + "SortBy.name.label": "Namn", + "SortBy.type.label": "Typ", + "SortBy.premise.label": "Lokal", + "SortBy.people.label": "Antal personer", "TestSiteMessage.text": "Detta är Varaamo testversion", "TimeRangeControl.timeRangeTitle": "Tidsurval och minsta reserveringstid", "TimeRangeControl.title": "{date} kl. {start}-{end} {hours}h bokning", @@ -281,4 +286,4 @@ "UserReservationsPage.regularEmptyMessage": "Det finns inga vanliga bokningar att visa.", "UserReservationsPage.regularReservationsHeader": "Vanliga bokningar", "UserReservationsPage.title": "Mina bokningar" -} \ No newline at end of file +} diff --git a/app/index.js b/app/index.js index 63b99124e..18ad32e50 100644 --- a/app/index.js +++ b/app/index.js @@ -1,5 +1,6 @@ +import 'react-app-polyfill/ie11'; +import { browserName } from 'react-device-detect'; import 'location-origin'; - import React from 'react'; import { render } from 'react-dom'; import { Provider } from 'react-intl-redux'; @@ -14,6 +15,7 @@ import { initI18n } from 'i18n'; import configureStore from 'store/configureStore'; import rootReducer from 'state/rootReducer'; import getRoutes from './routes'; +import BrowserWarning from './pages/browser-warning'; const initialStoreState = createStore(rootReducer, {}).getState(); const initialServerState = window.INITIAL_STATE; @@ -22,10 +24,13 @@ const finalState = Immutable(initialStoreState).merge([initialServerState, initi deep: true, }); const store = configureStore(finalState); +const isIEBrowser = browserName === 'IE'; -render( - - {getRoutes()} - , - document.getElementById('root') -); +// TODO: Support IE11 in the future. +render(isIEBrowser ? + : ( + + {getRoutes()} + + ), +document.getElementById('root')); diff --git a/app/pages/browser-warning/BrowserWarning.js b/app/pages/browser-warning/BrowserWarning.js new file mode 100644 index 000000000..20609b135 --- /dev/null +++ b/app/pages/browser-warning/BrowserWarning.js @@ -0,0 +1,43 @@ +import React from 'react'; + +function BrowserWarning() { + return ( +
+

+ Currently, Varaamo does not support Internet Explorer. + We are investigating this issue and finding a solution. + Meanwhile, use another browser (such as + Chrome + , + Firefox + or + Edge + ). +

+

+ Varaamo ei tue Internet Explorer selainta tällä hetkellä. + Selvitämme ongelmaa sen ratkaisemiseksi. + Sillä välin, käytä toista selainta (kuten + Chrome + , + Firefox + tai + Edge + ). +

+

+ Varaamo fungerar inte längre med Internet Explorer. + Vi arbetar med att lösa problemet. + Under tiden så var vänlig och använd någon annan browser (t.ex + Chrome + , + Firefox + eller + Edge + ). +

+
+ ); +} + +export default BrowserWarning; diff --git a/app/pages/browser-warning/BrowserWarning.spec.js b/app/pages/browser-warning/BrowserWarning.spec.js new file mode 100644 index 000000000..25aabf17e --- /dev/null +++ b/app/pages/browser-warning/BrowserWarning.spec.js @@ -0,0 +1,25 @@ +import React from 'react'; + +import BrowserWarning from './BrowserWarning'; +import { shallowWithIntl } from 'utils/testUtils'; + +describe('pages/browser-warning/BrowserWarning', () => { + function getWrapper() { + return shallowWithIntl(); + } + + test('renders a browser warning div', () => { + const div = getWrapper().find('div'); + expect(div.length).toBe(1); + }); + + test('renders a browser warning paragraph', () => { + const p = getWrapper().find('p'); + expect(p.length).toBe(3); + }); + + test('renders all specified browser links', () => { + const a = getWrapper().find('a'); + expect(a.length).toBe(9); + }); +}); diff --git a/app/pages/browser-warning/index.js b/app/pages/browser-warning/index.js new file mode 100644 index 000000000..2cb4f92e4 --- /dev/null +++ b/app/pages/browser-warning/index.js @@ -0,0 +1,3 @@ +import BrowserWarning from './BrowserWarning'; + +export default BrowserWarning; diff --git a/app/pages/search/SearchPage.js b/app/pages/search/SearchPage.js index efc078ebe..659fc1ae4 100644 --- a/app/pages/search/SearchPage.js +++ b/app/pages/search/SearchPage.js @@ -2,7 +2,10 @@ import isEqual from 'lodash/isEqual'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { connect } from 'react-redux'; +import queryString from 'query-string'; import { bindActionCreators } from 'redux'; +import Row from 'react-bootstrap/lib/Row'; +import Col from 'react-bootstrap/lib/Col'; import { searchResources, toggleMap } from 'actions/searchActions'; import { changeSearchFilters } from 'actions/uiActions'; @@ -14,12 +17,14 @@ import ResourceMap from 'shared/resource-map'; import SearchControls from './controls'; import searchPageSelector from './searchPageSelector'; import SearchResults from './results/SearchResults'; +import Sort from './Sort'; import MapToggle from './results/MapToggle'; class UnconnectedSearchPage extends Component { constructor(props) { super(props); this.searchResources = this.searchResources.bind(this); + this.sortResource = this.sortResource.bind(this); } componentDidMount() { @@ -68,6 +73,11 @@ class UnconnectedSearchPage extends Component { this.props.actions.searchResources({ ...filters, ...position }); } + sortResource(value) { + const filters = { ...this.props.filters, ...{ orderBy: value } }; + this.props.history.push(`/search?${queryString.stringify(filters)}`); + } + render() { const { actions, @@ -80,6 +90,7 @@ class UnconnectedSearchPage extends Component { searchDone, selectedUnitId, showMap, + filters, t, } = this.props; return ( @@ -96,7 +107,13 @@ class UnconnectedSearchPage extends Component { showMap={showMap} /> )} + + + + + +
{(searchDone || isFetchingSearchResults) && ( { expect(resourceMap.prop('selectedUnitId')).toBe(props.selectedUnitId); }); + test('renders an Row element', () => { + expect(getWrapper().find(Row)).toHaveLength(1); + }); + + test('renders a Sort component with correct props', () => { + const sort = getWrapper().find(Sort); + expect(sort).toHaveLength(1); + expect(typeof sort.prop('onChange')).toBe('function'); + }); + describe('SearchResults', () => { function getSearchResults(props) { return getWrapper(props).find(SearchResults); @@ -214,6 +226,23 @@ describe('pages/search/SearchPage', () => { }); }); + describe('sortResource', () => { + const pushMock = simple.mock(); + beforeAll(() => { + const instance = getWrapper( + { + history: { push: pushMock } + } + ).instance(); + instance.sortResource('name'); + }); + + test('changes history with correct queryString', () => { + expect(pushMock.callCount).toBe(1); + expect(pushMock.lastCall.args[0]).toContain('name'); + }); + }); + describe('if search filters did change and url has query part', () => { let nextProps; diff --git a/app/pages/search/Sort.js b/app/pages/search/Sort.js new file mode 100644 index 000000000..3c06a5cc1 --- /dev/null +++ b/app/pages/search/Sort.js @@ -0,0 +1,50 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; + +import { injectT } from 'i18n'; +import CONSTANTS from '../../constants/AppConstants'; +import SelectControl from './controls/SelectControl'; + +export class UnconnectedSort extends Component { + getSortOptions = () => { + const { lang, t } = this.props; + + return [ + { label: t('SortBy.name.label'), value: CONSTANTS.SORT_BY_OPTIONS.NAME.replace('lang', lang) }, + { label: t('SortBy.type.label'), value: CONSTANTS.SORT_BY_OPTIONS.TYPE.replace('lang', lang) }, + { label: t('SortBy.premise.label'), value: CONSTANTS.SORT_BY_OPTIONS.PREMISES.replace('lang', lang) }, + { label: t('SortBy.people.label'), value: CONSTANTS.SORT_BY_OPTIONS.PEOPLE }, + ]; + } + + render() { + return ( + this.props.onChange(value)} + options={this.getSortOptions()} + value={this.props.sortValue} + /> + ); + } +} + +const mapStateToProps = state => ({ + lang: state.intl.locale +}); + +export default connect( + mapStateToProps, + {} +)(injectT(UnconnectedSort)); + + +UnconnectedSort.propTypes = { + lang: PropTypes.string.isRequired, + t: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + sortValue: PropTypes.string, +}; diff --git a/app/pages/search/Sort.spec.js b/app/pages/search/Sort.spec.js new file mode 100644 index 000000000..45b33bad6 --- /dev/null +++ b/app/pages/search/Sort.spec.js @@ -0,0 +1,40 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import SelectControl from './controls/SelectControl'; +import { UnconnectedSort as Sort } from './Sort'; + +describe('pages/search/Sort', () => { + const defaultProps = { + sortValue: '', + t: value => value, + lang: 'en', + onChange: () => {}, + }; + + function getWrapper(props) { + return shallow(); + } + + describe('pages/search/Sort', () => { + test('renders SelectControl for sort with correct props', () => { + const wrapper = getWrapper({}); + const selectControl = wrapper.find(SelectControl); + expect(selectControl).toHaveLength(1); + expect(selectControl.prop('id')).toBe('app-Sort'); + expect(selectControl.prop('label')).toEqual('SortBy.label'); + expect(selectControl.prop('onChange')).toBeDefined(); + expect(selectControl.prop('options')).toBeDefined(); + expect(selectControl.prop('value')).toEqual(defaultProps.sortValue); + }); + + test('get translated options base on language', () => { + const wrapper = getWrapper({ lang: 'foo' }); + const options = wrapper.prop('options'); + + expect(options.length).toEqual(4); + expect(options[0].value).toContain('foo'); + expect(options[3].value).not.toContain('foo'); + }); + }); +}); diff --git a/app/pages/search/_search-page.scss b/app/pages/search/_search-page.scss index 45ce5647f..c1c94757b 100644 --- a/app/pages/search/_search-page.scss +++ b/app/pages/search/_search-page.scss @@ -13,6 +13,10 @@ position: relative; } + &__sortControlRow { + margin-top: 10px; + } + .app-MapToggle { background-color: $hel-copper; display: block; diff --git a/app/shared/resource-card/ResourceCard.js b/app/shared/resource-card/ResourceCard.js index e82b39351..1bcbf180b 100644 --- a/app/shared/resource-card/ResourceCard.js +++ b/app/shared/resource-card/ResourceCard.js @@ -2,21 +2,30 @@ import classnames from 'classnames'; import round from 'lodash/round'; import PropTypes from 'prop-types'; import queryString from 'query-string'; -import React, { Component } from 'react'; +import React, { Component, Fragment } from 'react'; import { Link } from 'react-router-dom'; -import Col from 'react-bootstrap/lib/Col'; +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; import iconHome from 'hel-icons/dist/shapes/home.svg'; import iconMapMarker from 'hel-icons/dist/shapes/map-marker.svg'; import iconTicket from 'hel-icons/dist/shapes/ticket.svg'; import iconUser from 'hel-icons/dist/shapes/user-o.svg'; +import iconHeart from 'hel-icons/dist/shapes/heart-o.svg'; -import UnpublishedLabel from 'shared/label/Unpublished'; import { injectT } from 'i18n'; +import iconHeartFilled from 'assets/icons/heart-filled.svg'; +import UnpublishedLabel from 'shared/label/Unpublished'; import iconMap from 'assets/icons/map.svg'; import BackgroundImage from 'shared/background-image'; import { getMainImage } from 'utils/imageUtils'; import { getHourlyPrice, getResourcePageUrlComponents } from 'utils/resourceUtils'; -import ResourceAvailability from './ResourceAvailability'; +import ResourceAvailability from './label'; +import ResourceCardInfoCell from './info'; +import resourceCardSelector from './resourceCardSelector'; +import { + favoriteResource, + unfavoriteResource +} from 'actions/resourceActions'; class ResourceCard extends Component { handleSearchByType = () => { @@ -59,7 +68,7 @@ class ResourceCard extends Component { render() { const { - date, resource, t, unit + date, resource, t, unit, actions, isLoggedIn } = this.props; const { pathname, query } = getResourcePageUrlComponents(resource, date); const linkTo = { @@ -97,83 +106,76 @@ class ResourceCard extends Component {
{resource.description}
+
- - - {resource.type.name} - - {resource.type ? resource.type.name : '\u00A0'} - - - - - - {resource.peopleCapacity} - - {t('ResourceCard.peopleCapacity', { people: resource.peopleCapacity })} - - - - -
- {resource.type.name} - - {getHourlyPrice(t, resource) || '\u00A0'} - -
- - -
- {resource.type.name} - + + + {resource.type ? resource.type.name : '\u00A0'} + + + + + + {t('ResourceCard.peopleCapacity', { people: resource.peopleCapacity })} + + + + + + {getHourlyPrice(t, resource) || '\u00A0'} + + + + + + {unit.streetAddress} - + {unit.addressZip} {' '} {unit.municipality} -
- - - - {resource.type.name} - - {resource.distance ? this.renderDistance(resource.distance) : '\u00A0'} - - - + + + + + + {resource.distance ? this.renderDistance(resource.distance) : '\u00A0'} + + + + {isLoggedIn + && ( + actions.unfavoriteResource(resource.id) + : () => actions.favoriteResource(resource.id) + } + /> + ) + }
); @@ -188,6 +190,22 @@ ResourceCard.propTypes = { stacked: PropTypes.bool, t: PropTypes.func.isRequired, unit: PropTypes.object.isRequired, + actions: PropTypes.object, + isLoggedIn: PropTypes.bool, }; -export default injectT(ResourceCard); +const UnconnectedResourceCard = injectT(ResourceCard); + + +function mapDispatchToProps(dispatch) { + const actionCreators = { + favoriteResource, + unfavoriteResource, + }; + + return { actions: bindActionCreators(actionCreators, dispatch) }; +} + +export { UnconnectedResourceCard }; + +export default connect(resourceCardSelector, mapDispatchToProps)(UnconnectedResourceCard); diff --git a/app/shared/resource-card/ResourceCard.spec.js b/app/shared/resource-card/ResourceCard.spec.js index 5f09bc6c0..9d6f3d193 100644 --- a/app/shared/resource-card/ResourceCard.spec.js +++ b/app/shared/resource-card/ResourceCard.spec.js @@ -2,6 +2,7 @@ import React from 'react'; import { Link } from 'react-router-dom'; import Immutable from 'seamless-immutable'; import simple from 'simple-mock'; +import iconHeart from 'hel-icons/dist/shapes/heart-o.svg'; import BackgroundImage from 'shared/background-image'; import Image from 'utils/fixtures/Image'; @@ -9,9 +10,11 @@ import Resource from 'utils/fixtures/Resource'; import Unit from 'utils/fixtures/Unit'; import { getResourcePageUrlComponents } from 'utils/resourceUtils'; import { shallowWithIntl } from 'utils/testUtils'; -import ResourceAvailability from './ResourceAvailability'; -import ResourceCard from './ResourceCard'; +import ResourceAvailability from './label/ResourceAvailability'; +import { UnconnectedResourceCard } from './ResourceCard'; import UnpublishedLabel from 'shared/label/Unpublished'; +import ResourceCardInfoCell from './info'; +import iconHeartWhite from 'assets/icons/heart-white.svg'; describe('shared/resource-card/ResourceCard', () => { function getResource(extra) { @@ -44,6 +47,10 @@ describe('shared/resource-card/ResourceCard', () => { }; const defaultProps = { + actions: { + favoriteResource: jest.fn(), + unfavoriteResource: jest.fn(), + }, history, date: '2015-10-10', isLoggedIn: false, @@ -67,7 +74,7 @@ describe('shared/resource-card/ResourceCard', () => { }; function getWrapper(extraProps) { - return shallowWithIntl(); + return shallowWithIntl(); } test('renders an div element', () => { @@ -98,6 +105,67 @@ describe('shared/resource-card/ResourceCard', () => { }); }); + describe('info box', () => { + test('render with 5 ResourceCardInfoCell cells', () => { + const info = getWrapper().find('.app-ResourceCard__info'); + const cells = info.find(ResourceCardInfoCell); + expect(cells.length).toEqual(5); + }); + + test('render with first ResourceCardInfoCell', () => { + const info = getWrapper().find('.app-ResourceCard__info'); + const cell = info.find(ResourceCardInfoCell).first(); + + expect(cell.prop('alt')).toEqual(defaultProps.resource.type.name); + expect(cell.prop('icon')).toBeDefined(); + expect(cell.prop('onClick')).toBeDefined(); + }); + + test('will not render favorite icon as default if user not logged in', () => { + const info = getWrapper().find('.app-ResourceCard__info'); + const cell = info.find(ResourceCardInfoCell)[5]; + + expect(cell).toBeUndefined(); + }); + + test('render with favorite icon as default if user logged in', () => { + const info = getWrapper({ isLoggedIn: true }).find('.app-ResourceCard__info'); + const cell = info.find(ResourceCardInfoCell).last(); + + expect(cell.prop('alt')).toEqual(defaultProps.resource.type.name); + expect(cell.prop('icon')).toEqual(iconHeart); + expect(cell.prop('onClick')).toBeDefined(); + }); + + test('render with unfavorite icon when isFavorite is true, user logged in', () => { + const info = getWrapper({ + resource: getResource({ isFavorite: true, isLoggedIn: true }) + }).find('.app-ResourceCard__info'); + const cell = info.find(ResourceCardInfoCell).last(); + + expect(cell.prop('alt')).toEqual(defaultProps.resource.type.name); + expect(cell.prop('icon')).toEqual(iconHeartWhite); + expect(cell.prop('onClick')).toBeDefined(); + }); + + test('invoke favorite func when favorite icon is clicked as default, user logged in', () => { + const info = getWrapper({ isLoggedIn: true }).find('.app-ResourceCard__info'); + const cell = info.find(ResourceCardInfoCell).last(); + cell.simulate('click'); + expect(defaultProps.actions.favoriteResource).toHaveBeenCalledTimes(1); + }); + + test('invoke set unfavorite func when favorite icon is clicked, user logged in', () => { + const info = getWrapper({ + resource: getResource({ isFavorite: true }), + isLoggedIn: true + }).find('.app-ResourceCard__info'); + const cell = info.find(ResourceCardInfoCell).last(); + cell.simulate('click'); + expect(defaultProps.actions.unfavoriteResource).toHaveBeenCalledTimes(1); + }); + }); + describe('people capacity', () => { test('renders people capacity', () => { const peopleCapacity = getWrapper().find('.app-ResourceCard__peopleCapacity'); @@ -215,13 +283,6 @@ describe('shared/resource-card/ResourceCard', () => { expect(zipAddress.html()).toContain(defaultProps.unit.municipality); }); - test('renders an anchor that calls handleSearchByType on click', () => { - const wrapper = getWrapper(); - const typeAnchor = wrapper.find('.app-ResourceCard__info-link-capitalize').filter('a'); - expect(typeAnchor).toHaveLength(1); - expect(typeAnchor.prop('onClick')).toBe(wrapper.instance().handleSearchByType); - }); - test('renders an anchor that calls handleSearchByUnitName on click', () => { const wrapper = getWrapper(); const unitAnchor = wrapper.find('.app-ResourceCard__unit-name-link'); diff --git a/app/shared/resource-card/ResourceCardContainer.js b/app/shared/resource-card/ResourceCardContainer.js deleted file mode 100644 index eb50c38f8..000000000 --- a/app/shared/resource-card/ResourceCardContainer.js +++ /dev/null @@ -1,6 +0,0 @@ -import { connect } from 'react-redux'; - -import resourceCardSelector from './resourceCardSelector'; -import ResourceCard from './ResourceCard'; - -export default connect(resourceCardSelector)(ResourceCard); diff --git a/app/shared/resource-card/_resource-card.scss b/app/shared/resource-card/_resource-card.scss index c6ead5d83..a22af2d69 100644 --- a/app/shared/resource-card/_resource-card.scss +++ b/app/shared/resource-card/_resource-card.scss @@ -1,6 +1,10 @@ $card-border-color: #d6d6d6; +$image-items-margin: 5px; +$resourceCardPadding: 15px; +$cellWidth: 33.33%; +$cellHeight: 50%; + .app-ResourceCard { - $image-items-margin: 5px; margin: 20px 0; background-color: white; display: flow-root; @@ -45,7 +49,7 @@ $card-border-color: #d6d6d6; } &__content { - padding: 15px; + padding: $resourceCardPadding; @media (min-width: $screen-md-min) { flex: 1 2 40%; overflow: hidden; @@ -93,25 +97,39 @@ $card-border-color: #d6d6d6; } &__info { - font-size: 1.2rem; - font-weight: bold; - text-align: center; - &-link, - &-detail { - display: inline-block; - &-capitalize { + padding: $resourceCardPadding; + display: flex; + flex-wrap: wrap; + + &-cell { + border: none; + background: transparent; + display: inline-flex; + flex-direction: column; + width: $cellWidth; + height: $cellHeight; + align-items: center; + &:hover, + &:focus { + outline: none; + &:active { + outline: none; + } + box-shadow: none; + } + + &__icon { + width: $line-height-computed*1.2; + } + + span { + font-size: 1.2rem; + font-weight: bold; + text-align: center; text-transform: capitalize; } } - > div { - padding-top: 15px; - } - &-label { - display: block; - } - &-icon { - width: $line-height-computed*1.2; - } + @media (min-width: $screen-xs-min) { display: inline-block; width: 100%; diff --git a/app/shared/resource-card/index.js b/app/shared/resource-card/index.js index 3738ea4d0..20da4cc63 100644 --- a/app/shared/resource-card/index.js +++ b/app/shared/resource-card/index.js @@ -1,3 +1,3 @@ -import ResourceCardContainer from './ResourceCardContainer'; +import ResourceCard from './ResourceCard'; -export default ResourceCardContainer; +export default ResourceCard; diff --git "a/app/shared/resource-card/info/ResourceCard\bInfoCell.spec.js" "b/app/shared/resource-card/info/ResourceCard\bInfoCell.spec.js" new file mode 100644 index 000000000..d271c5c18 --- /dev/null +++ "b/app/shared/resource-card/info/ResourceCard\bInfoCell.spec.js" @@ -0,0 +1,64 @@ +import { shallow } from 'enzyme'; +import React from 'react'; + +import ResourceCardInfoCell from './ResourceCardInfoCell'; +import iconMap from 'assets/icons/map.svg'; + +describe('/shared/resource-card/info/ResourceCardInfoCell', () => { + const defaultProps = { + className: 'app-ResourceCard__info-cell', + alt: 'foo', + icon: 'bar', + onClick: () => {} + }; + + function getWrapper(props) { + return shallow(); + } + + test('render normally', () => { + const wrapper = getWrapper(); + expect(wrapper).toHaveLength(1); + expect(wrapper).toBeDefined(); + }); + + test('contains default className, joined with passed className prop', () => { + const wrapper = getWrapper({ className: 'foo' }); + const classnames = wrapper.prop('className'); + expect(classnames).toContain(defaultProps.className); + expect(classnames).toContain('foo'); + }); + + test('contain onClick handler', () => { + const mockFunc = jest.fn(); + const wrapper = getWrapper({ onClick: mockFunc }); + + wrapper.simulate('click'); + expect(mockFunc).toBeCalled(); + expect(wrapper.prop('onClick')).toBeDefined(); + expect(mockFunc.mock.calls.length).toBe(1); + }); + + test('contains img with props', () => { + const wrapper = getWrapper(); + const img = wrapper.find('img'); + + expect(img.prop('alt')).toBe(defaultProps.alt); + expect(img.prop('src')).toBe(defaultProps.icon); + expect(img.prop('className')).toBe('app-ResourceCard__info-cell__icon'); + }); + + test('accept external icon', () => { + const wrapper = getWrapper({ icon: iconMap }); + const img = wrapper.find('img'); + + expect(img.prop('src')).toBe(iconMap); + }); + + test('accept children as label', () => { + const label = This is label; + const wrapper = getWrapper({ children: label }); + + expect(wrapper.contains(label)).toBeTruthy(); + }); +}); diff --git a/app/shared/resource-card/info/ResourceCardInfoCell.js b/app/shared/resource-card/info/ResourceCardInfoCell.js new file mode 100644 index 000000000..bae87a8d1 --- /dev/null +++ b/app/shared/resource-card/info/ResourceCardInfoCell.js @@ -0,0 +1,29 @@ +import React from 'react'; +import Button from 'react-bootstrap/lib/Button'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +function ResourceCardInfoCell({ + className, alt, icon, onClick, children +}) { + return ( + + ); +} + +ResourceCardInfoCell.propTypes = { + className: PropTypes.string, + alt: PropTypes.string, + children: PropTypes.element, + icon: PropTypes.string, + onClick: PropTypes.func, +}; + +export default ResourceCardInfoCell; diff --git a/app/shared/resource-card/info/index.js b/app/shared/resource-card/info/index.js new file mode 100644 index 000000000..618e2558c --- /dev/null +++ b/app/shared/resource-card/info/index.js @@ -0,0 +1,3 @@ +import ResourceCardInfoCell from './ResourceCardInfoCell'; + +export default ResourceCardInfoCell; diff --git a/app/shared/resource-card/ResourceAvailability.js b/app/shared/resource-card/label/ResourceAvailability.js similarity index 100% rename from app/shared/resource-card/ResourceAvailability.js rename to app/shared/resource-card/label/ResourceAvailability.js diff --git a/app/shared/resource-card/ResourceAvailability.spec.js b/app/shared/resource-card/label/ResourceAvailability.spec.js similarity index 100% rename from app/shared/resource-card/ResourceAvailability.spec.js rename to app/shared/resource-card/label/ResourceAvailability.spec.js diff --git a/app/shared/resource-card/label/index.js b/app/shared/resource-card/label/index.js new file mode 100644 index 000000000..781b55cfd --- /dev/null +++ b/app/shared/resource-card/label/index.js @@ -0,0 +1,3 @@ +import ResourceAvailability from './ResourceAvailability'; + +export default ResourceAvailability; diff --git a/app/shared/resource-card/resourceListItemSelector.spec.js b/app/shared/resource-card/resourceCardSelector.spec.js similarity index 100% rename from app/shared/resource-card/resourceListItemSelector.spec.js rename to app/shared/resource-card/resourceCardSelector.spec.js diff --git a/app/state/reducers/ui/searchReducer.js b/app/state/reducers/ui/searchReducer.js index 4eb4ec17b..17ccfbc6a 100644 --- a/app/state/reducers/ui/searchReducer.js +++ b/app/state/reducers/ui/searchReducer.js @@ -17,6 +17,7 @@ const initialState = Immutable({ start: '', end: '', useTimeRange: false, + orderBy: '', }, page: 1, position: null, diff --git a/app/state/reducers/ui/searchReducer.spec.js b/app/state/reducers/ui/searchReducer.spec.js index 6ad44003b..63556e6ab 100644 --- a/app/state/reducers/ui/searchReducer.spec.js +++ b/app/state/reducers/ui/searchReducer.spec.js @@ -180,6 +180,7 @@ describe('state/reducers/ui/searchReducer', () => { search: '', start: '', useTimeRange: false, + orderBy: '' }; const action = clearSearchResults(); const initialState = Immutable({ filters }); diff --git a/app/state/selectors/__tests__/uiSearchFiltersSelector.spec.js b/app/state/selectors/__tests__/uiSearchFiltersSelector.spec.js index 711ee2eab..9e445e52d 100644 --- a/app/state/selectors/__tests__/uiSearchFiltersSelector.spec.js +++ b/app/state/selectors/__tests__/uiSearchFiltersSelector.spec.js @@ -20,6 +20,7 @@ function getState(date = '2015-10-10', start = '08:30', freeOfCharge = '') { start, unit: '', useTimeRange: false, + orderBy: '' }, }, }, diff --git a/app/state/selectors/__tests__/urlSearchFiltersSelector.spec.js b/app/state/selectors/__tests__/urlSearchFiltersSelector.spec.js index 4208ba35d..eaa00a0a8 100644 --- a/app/state/selectors/__tests__/urlSearchFiltersSelector.spec.js +++ b/app/state/selectors/__tests__/urlSearchFiltersSelector.spec.js @@ -17,6 +17,7 @@ describe('Selector: urlSearchFiltersSelector', () => { unit: '', useTimeRange: false, municipality: '', + orderBy: '' }; const getProps = ( diff --git a/app/utils/fixtures/Resource.js b/app/utils/fixtures/Resource.js index 9b5c556e5..dc5d00046 100644 --- a/app/utils/fixtures/Resource.js +++ b/app/utils/fixtures/Resource.js @@ -13,6 +13,6 @@ const Resource = new Factory() .attr('reservable', true) .attr('reservableAfter', null) .attr('supportedReservationExtraFields', []) - .attr('userPermissions', { isAdmin: false, canMakeReservations: true }); - + .attr('userPermissions', { isAdmin: false, canMakeReservations: true }) + .attr('isFavorite', false); export default Resource; diff --git a/jest.config.js b/jest.config.js index 9951545ab..a917dc335 100755 --- a/jest.config.js +++ b/jest.config.js @@ -11,7 +11,7 @@ module.exports = { clearMocks: true, // An array of glob patterns indicating a set of files for which coverage information should be collected - collectCoverageFrom: ['app/**/*.{js,jsx,mjs}'], + collectCoverageFrom: ['app/**/*.{js,jsx,mjs}', '"!app/index.js"'], // The directory where Jest should output its coverage files coverageDirectory: 'coverage', @@ -48,6 +48,11 @@ module.exports = { '/node_modules/', ], + // ignore watch to include node_modules by mistake. + watchPathIgnorePatterns: [ + '/node_modules/', + ], + // Indicates whether each individual test should be reported during the run verbose: false, }; diff --git a/package.json b/package.json index 6bf20b434..d4e98e234 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "varaamo", - "version": "0.1.1", + "version": "0.2.0", "repository": { "type": "git", "url": "https://github.com/City-of-Helsinki/varaamo" @@ -41,8 +41,10 @@ "query-string": "4.2.3", "rc-slider": "8.3.1", "react": "16.8.4", + "react-app-polyfill": "^0.2.2", "react-bootstrap": "0.32.3", "react-day-picker": "7.3.0", + "react-device-detect": "^1.6.2", "react-dom": "16.8.4", "react-helmet": "5.2.0", "react-intl": "2.8.0", diff --git a/yarn.lock b/yarn.lock index 2f98b74bb..02029819c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1249,7 +1249,7 @@ arrify@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" -asap@~2.0.3: +asap@~2.0.3, asap@~2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" @@ -2039,6 +2039,11 @@ copy-descriptor@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" +core-js@2.6.4: + version "2.6.4" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.4.tgz#b8897c062c4d769dd30a0ac5c73976c47f92ea0d" + integrity sha512-05qQ5hXShcqGkPZpXEFLIpxayZscVD2kuMBZewxiIPPEagukO4mqgPA9CWhUvFBJfy3ODdK2p9xyHh7FTU9/7A== + core-js@^1.0.0: version "1.2.7" resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636" @@ -5382,7 +5387,7 @@ oauth@0.9.x: version "0.9.15" resolved "https://registry.yarnpkg.com/oauth/-/oauth-0.9.15.tgz#bd1fefaf686c96b75475aed5196412ff60cfb9c1" -object-assign@4.x, object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: +object-assign@4.1.1, object-assign@4.x, object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" @@ -5932,6 +5937,13 @@ promise-inflight@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" +promise@8.0.2: + version "8.0.2" + resolved "https://registry.yarnpkg.com/promise/-/promise-8.0.2.tgz#9dcd0672192c589477d56891271bdc27547ae9f0" + integrity sha512-EIyzM39FpVOMbqgzEHhxdrEhtOSDOtjMZQ0M6iVfCE+kWNgCkAyOdnuCWqfmflylftfadU6FkiMgHZA2kUzwRw== + dependencies: + asap "~2.0.6" + promise@^7.1.1: version "7.3.1" resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" @@ -6044,7 +6056,7 @@ querystring@0.2.0, querystring@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620" -raf@^3.4.0: +raf@3.4.1, raf@^3.4.0: version "3.4.1" resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39" dependencies: @@ -6156,6 +6168,17 @@ rc@^1.2.7: minimist "^1.2.0" strip-json-comments "~2.0.1" +react-app-polyfill@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/react-app-polyfill/-/react-app-polyfill-0.2.2.tgz#a903b61a8bfd9c5e5f16fc63bebe44d6922a44fb" + integrity sha512-mAYn96B/nB6kWG87Ry70F4D4rsycU43VYTj3ZCbKP+SLJXwC0x6YCbwcICh3uW8/C9s1VgP197yx+w7SCWeDdQ== + dependencies: + core-js "2.6.4" + object-assign "4.1.1" + promise "8.0.2" + raf "3.4.1" + whatwg-fetch "3.0.0" + react-bootstrap@0.32.3: version "0.32.3" resolved "https://registry.yarnpkg.com/react-bootstrap/-/react-bootstrap-0.32.3.tgz#7ac6f3a8ca099b22d2a8ebb091ab2ed7d39050cf" @@ -6186,6 +6209,11 @@ react-deep-force-update@^1.0.0: version "1.1.2" resolved "https://registry.yarnpkg.com/react-deep-force-update/-/react-deep-force-update-1.1.2.tgz#3d2ae45c2c9040cbb1772be52f8ea1ade6ca2ee1" +react-device-detect@^1.6.2: + version "1.6.2" + resolved "https://registry.yarnpkg.com/react-device-detect/-/react-device-detect-1.6.2.tgz#2587e4d1dc15bdfce7a1bf5417624ecd2a2fc31f" + integrity sha512-XIBgwIfpGAknm7tXe/YNbx4ieIR7IyFI3KNfSQk4UjHVy97UHe/nB7iJj8R/dDsI+I/ZzPR4HJ39Gh5tI4nhxw== + react-dom@16.8.4: version "16.8.4" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.8.4.tgz#1061a8e01a2b3b0c8160037441c3bf00a0e3bc48" @@ -7941,7 +7969,7 @@ whatwg-encoding@^1.0.1, whatwg-encoding@^1.0.3: dependencies: iconv-lite "0.4.24" -whatwg-fetch@>=0.10.0: +whatwg-fetch@3.0.0, whatwg-fetch@>=0.10.0: version "3.0.0" resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz#fc804e458cc460009b1a2b966bc8817d2578aefb"