diff --git a/src/components/Menu/Menu.js b/src/components/Menu/Menu.js
index 17b85707..6f5abbff 100644
--- a/src/components/Menu/Menu.js
+++ b/src/components/Menu/Menu.js
@@ -24,6 +24,12 @@ import css from './Menu.css';
const KEY_CODE_ESCAPE = 27;
const CONTENT_PLACEMENT_OFFSET = 0;
+const CONTENT_TO_LEFT = 'left';
+const CONTENT_TO_RIGHT = 'right';
+
+const isControlledMenu = (isOpenProp, onToggleActiveProp) => {
+ return isOpenProp !== null && onToggleActiveProp !== null;
+};
// This should work, but it doesn't
{}} role="button" />
/* eslint-disable jsx-a11y/no-static-element-interactions */
@@ -33,6 +39,16 @@ class Menu extends Component {
this.state = { isOpen: false };
+ const { isOpen, onToggleActive } = props;
+ const isIndependentMenu = isOpen === null && onToggleActive === null;
+ if (!(isIndependentMenu || isControlledMenu(isOpen, onToggleActive))) {
+ throw new Error(
+ `Menu has invalid props:
+ Both isOpen and onToggleActive need to be defined (controlled menu),
+ or neither of them (menu uses its own state management).`
+ );
+ }
+
this.onBlur = this.onBlur.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
this.toggleOpen = this.toggleOpen.bind(this);
@@ -60,20 +76,30 @@ class Menu extends Component {
}
toggleOpen(enforcedState) {
- this.setState(prevState => {
- const isOpen = enforcedState != null ? enforcedState : !prevState.isOpen;
- return { isOpen };
- });
+ // If state is handled outside of Menu component, we call a passed in onToggleActive func
+ const { isOpen, onToggleActive } = this.props;
+ if (isControlledMenu(isOpen, onToggleActive)) {
+ const isMenuOpen = enforcedState != null ? enforcedState : !isOpen;
+ onToggleActive(isMenuOpen);
+ } else {
+ // If state is handled inside of Menu component, set state
+ this.setState(prevState => {
+ const isMenuOpen = enforcedState != null ? enforcedState : !prevState.isOpen;
+ return { isOpen: isMenuOpen };
+ });
+ }
}
- positionStyleForMenuContent() {
+ positionStyleForMenuContent(contentPosition) {
if (this.menu && this.menuContent) {
// Calculate wether we should show the menu to the left of the component or right
const distanceToRight = window.innerWidth - this.menu.getBoundingClientRect().right;
const menuWidth = this.menu.offsetWidth;
const contentWidthBiggerThanLabel = this.menuContent.offsetWidth - menuWidth;
- return distanceToRight < contentWidthBiggerThanLabel
- ? { right: -1 * CONTENT_PLACEMENT_OFFSET, minWidth: menuWidth }
+ const usePositionRightFromLabel = contentPosition === CONTENT_TO_LEFT;
+ const contentPlacementOffset = this.props.contentPlacementOffset;
+ return usePositionRightFromLabel || distanceToRight < contentWidthBiggerThanLabel
+ ? { right: contentPlacementOffset, minWidth: menuWidth }
: { left: 0, minWidth: menuWidth };
}
return {};
@@ -82,8 +108,9 @@ class Menu extends Component {
positionStyleForArrow(isPositionedRight) {
if (this.menu) {
const menuWidth = this.menu.offsetWidth;
+ const contentPlacementOffset = this.props.contentPlacementOffset;
return isPositionedRight
- ? Math.floor(menuWidth / 2) + CONTENT_PLACEMENT_OFFSET
+ ? Math.floor(menuWidth / 2) - contentPlacementOffset
: Math.floor(menuWidth / 2);
}
return 0;
@@ -95,23 +122,30 @@ class Menu extends Component {
}
return React.Children.map(this.props.children, child => {
+ const { isOpen: isOpenProp, onToggleActive } = this.props;
+ const isOpen = isControlledMenu(isOpenProp, onToggleActive) ? isOpenProp : this.state.isOpen;
+
if (child.type === MenuLabel) {
// MenuLabel needs toggleOpen function
// We pass that directly so that component user doesn't need to worry about that
return React.cloneElement(child, {
- isOpen: this.state.isOpen,
+ isOpen,
onToggleActive: this.toggleOpen,
});
} else if (child.type === MenuContent) {
// MenuContent needs some styling data (width, arrowPosition, and isOpen info)
// We pass those directly so that component user doesn't need to worry about those.
- const positionStyles = this.positionStyleForMenuContent();
+ const { contentPosition, useArrow } = this.props;
+ const positionStyles = this.positionStyleForMenuContent(contentPosition);
+ const arrowPosition = useArrow
+ ? this.positionStyleForArrow(positionStyles.right != null)
+ : null;
return React.cloneElement(child, {
- arrowPosition: this.positionStyleForArrow(positionStyles.right != null),
+ arrowPosition,
contentRef: node => {
this.menuContent = node;
},
- isOpen: this.state.isOpen,
+ isOpen,
style: { ...child.props.style, ...positionStyles },
});
} else {
@@ -142,14 +176,27 @@ class Menu extends Component {
}
/* eslint-enable jsx-a11y/no-static-element-interactions */
-Menu.defaultProps = { className: null, rootClassName: '' };
+Menu.defaultProps = {
+ className: null,
+ rootClassName: '',
+ contentPlacementOffset: CONTENT_PLACEMENT_OFFSET,
+ contentPosition: CONTENT_TO_RIGHT,
+ isOpen: null,
+ onToggleActive: null,
+ useArrow: true,
+};
-const { node, string } = PropTypes;
+const { bool, func, node, number, string } = PropTypes;
Menu.propTypes = {
children: node.isRequired,
className: string,
rootClassName: string,
+ contentPosition: string,
+ contentPlacementOffset: number,
+ useArrow: bool,
+ isOpen: bool,
+ onToggleActive: func,
};
export default Menu;
diff --git a/src/components/MenuLabel/MenuLabel.js b/src/components/MenuLabel/MenuLabel.js
index 38fb045e..db85ca89 100644
--- a/src/components/MenuLabel/MenuLabel.js
+++ b/src/components/MenuLabel/MenuLabel.js
@@ -17,6 +17,8 @@ class MenuLabel extends Component {
}
onClick(e) {
+ e.stopPropagation();
+ e.preventDefault();
this.props.onToggleActive();
// Don't show focus outline if user just clicked the element with mouse
diff --git a/src/components/SpinnerIcon/SpinnerIcon.css b/src/components/SpinnerIcon/SpinnerIcon.css
new file mode 100644
index 00000000..c3a3cc1f
--- /dev/null
+++ b/src/components/SpinnerIcon/SpinnerIcon.css
@@ -0,0 +1,7 @@
+@import '../../marketplace.css';
+
+.root {
+ width: 100px;
+ height: 100px;
+ stroke: var(--marketplaceColor);
+}
diff --git a/src/components/SpinnerIcon/SpinnerIcon.js b/src/components/SpinnerIcon/SpinnerIcon.js
new file mode 100644
index 00000000..f5b40e95
--- /dev/null
+++ b/src/components/SpinnerIcon/SpinnerIcon.js
@@ -0,0 +1,55 @@
+import React, { PropTypes } from 'react';
+import classNames from 'classnames';
+
+import css from './SpinnerIcon.css';
+
+// TODO: SVG needs to be changed so that it doesn't take 100x100 area,
+// but just a little bit more than what actual spinner takes (~26px)
+const SpinnerIcon = props => {
+ const { rootClassName, className } = props;
+ const classes = classNames(rootClassName || css.root, className);
+ return (
+
+ );
+};
+
+SpinnerIcon.defaultProps = {
+ rootClassName: null,
+ className: null,
+};
+
+const { string } = PropTypes;
+
+SpinnerIcon.propTypes = {
+ rootClassName: string,
+ className: string,
+};
+
+export default SpinnerIcon;
diff --git a/src/components/index.js b/src/components/index.js
index f48a623c..b547b49a 100644
--- a/src/components/index.js
+++ b/src/components/index.js
@@ -49,6 +49,7 @@ import SearchMapInfoCard from './SearchMapInfoCard/SearchMapInfoCard';
import SearchMapPriceLabel from './SearchMapPriceLabel/SearchMapPriceLabel';
import SearchResultsPanel from './SearchResultsPanel/SearchResultsPanel';
import SelectField from './SelectField/SelectField';
+import SpinnerIcon from './SpinnerIcon/SpinnerIcon';
import StripeBankAccountTokenInputField
from './StripeBankAccountTokenInputField/StripeBankAccountTokenInputField';
import TabNav from './TabNav/TabNav';
@@ -117,6 +118,7 @@ export {
SearchResultsPanel,
SecondaryButton,
SelectField,
+ SpinnerIcon,
StripeBankAccountTokenInputField,
TabNav,
TabNavHorizontal,
diff --git a/src/containers/ManageListingsPage/ManageListingsPage.duck.js b/src/containers/ManageListingsPage/ManageListingsPage.duck.js
index a2712f3d..4591280b 100644
--- a/src/containers/ManageListingsPage/ManageListingsPage.duck.js
+++ b/src/containers/ManageListingsPage/ManageListingsPage.duck.js
@@ -6,6 +6,14 @@ export const FETCH_LISTINGS_REQUEST = 'app/ManageListingsPage/FETCH_LISTINGS_REQ
export const FETCH_LISTINGS_SUCCESS = 'app/ManageListingsPage/FETCH_LISTINGS_SUCCESS';
export const FETCH_LISTINGS_ERROR = 'app/ManageListingsPage/FETCH_LISTINGS_ERROR';
+export const OPEN_LISTING_REQUEST = 'app/ManageListingsPage/OPEN_LISTING_REQUEST';
+export const OPEN_LISTING_SUCCESS = 'app/ManageListingsPage/OPEN_LISTING_SUCCESS';
+export const OPEN_LISTING_ERROR = 'app/ManageListingsPage/OPEN_LISTING_ERROR';
+
+export const CLOSE_LISTING_REQUEST = 'app/ManageListingsPage/CLOSE_LISTING_REQUEST';
+export const CLOSE_LISTING_SUCCESS = 'app/ManageListingsPage/CLOSE_LISTING_SUCCESS';
+export const CLOSE_LISTING_ERROR = 'app/ManageListingsPage/CLOSE_LISTING_ERROR';
+
export const ADD_OWN_ENTITIES = 'app/ManageListingsPage/ADD_OWN_ENTITIES';
// ================ Reducer ================ //
@@ -17,6 +25,10 @@ const initialState = {
queryListingsError: null,
currentPageResultIds: [],
ownEntities: {},
+ openingListing: null,
+ openingListingError: null,
+ closingListing: null,
+ closingListingError: null,
};
const resultIds = data => data.data.map(l => l.id);
@@ -28,6 +40,19 @@ const merge = (state, apiResponse) => {
};
};
+const updateListingAttributes = (state, listingEntity) => {
+ const oldListing = state.ownEntities.listing[listingEntity.id.uuid];
+ const updatedListing = { ...oldListing, attributes: listingEntity.attributes };
+ const ownListingEntities = {
+ ...state.ownEntities.listing,
+ [listingEntity.id.uuid]: updatedListing,
+ };
+ return {
+ ...state,
+ ownEntities: { ...state.ownEntities, listing: ownListingEntities },
+ };
+};
+
const manageListingsPageReducer = (state = initialState, action = {}) => {
const { type, payload } = action;
switch (type) {
@@ -51,6 +76,54 @@ const manageListingsPageReducer = (state = initialState, action = {}) => {
console.error(payload);
return { ...state, queryInProgress: false, queryListingsError: payload };
+ case OPEN_LISTING_REQUEST:
+ return {
+ ...state,
+ openingListing: payload.listingId,
+ openingListingError: null,
+ };
+ case OPEN_LISTING_SUCCESS:
+ return {
+ ...updateListingAttributes(state, payload.data),
+ openingListing: null,
+ };
+ case OPEN_LISTING_ERROR: {
+ // eslint-disable-next-line no-console
+ console.error(payload);
+ return {
+ ...state,
+ openingListing: null,
+ openingListingError: {
+ listingId: state.openingListing,
+ error: payload,
+ },
+ };
+ }
+
+ case CLOSE_LISTING_REQUEST:
+ return {
+ ...state,
+ closingListing: payload.listingId,
+ closingListingError: null,
+ };
+ case CLOSE_LISTING_SUCCESS:
+ return {
+ ...updateListingAttributes(state, payload.data),
+ closingListing: null,
+ };
+ case CLOSE_LISTING_ERROR: {
+ // eslint-disable-next-line no-console
+ console.error(payload);
+ return {
+ ...state,
+ closingListing: null,
+ closingListingError: {
+ listingId: state.closingListing,
+ error: payload,
+ },
+ };
+ }
+
case ADD_OWN_ENTITIES:
return merge(state, payload);
@@ -110,6 +183,38 @@ export const addOwnEntities = apiResponse => ({
payload: apiResponse,
});
+export const openListingRequest = listingId => ({
+ type: OPEN_LISTING_REQUEST,
+ payload: { listingId },
+});
+
+export const openListingSuccess = response => ({
+ type: OPEN_LISTING_SUCCESS,
+ payload: response.data,
+});
+
+export const openListingError = e => ({
+ type: OPEN_LISTING_ERROR,
+ error: true,
+ payload: e,
+});
+
+export const closeListingRequest = listingId => ({
+ type: CLOSE_LISTING_REQUEST,
+ payload: { listingId },
+});
+
+export const closeListingSuccess = response => ({
+ type: CLOSE_LISTING_SUCCESS,
+ payload: response.data,
+});
+
+export const closeListingError = e => ({
+ type: CLOSE_LISTING_ERROR,
+ error: true,
+ payload: e,
+});
+
export const queryListingsRequest = queryParams => ({
type: FETCH_LISTINGS_REQUEST,
payload: { queryParams },
@@ -126,6 +231,7 @@ export const queryListingsError = e => ({
payload: e,
});
+// Throwing error for new (loadData may need that info)
export const queryOwnListings = queryParams =>
(dispatch, getState, sdk) => {
dispatch(queryListingsRequest(queryParams));
@@ -145,3 +251,33 @@ export const queryOwnListings = queryParams =>
throw e;
});
};
+
+export const closeListing = listingId =>
+ (dispatch, getState, sdk) => {
+ dispatch(closeListingRequest(listingId));
+
+ return sdk.listings
+ .close({ id: listingId }, { expand: true })
+ .then(response => {
+ dispatch(closeListingSuccess(response));
+ return response;
+ })
+ .catch(e => {
+ dispatch(closeListingError(e));
+ });
+ };
+
+export const openListing = listingId =>
+ (dispatch, getState, sdk) => {
+ dispatch(openListingRequest(listingId));
+
+ return sdk.listings
+ .open({ id: listingId }, { expand: true })
+ .then(response => {
+ dispatch(openListingSuccess(response));
+ return response;
+ })
+ .catch(e => {
+ dispatch(openListingError(e));
+ });
+ };
diff --git a/src/containers/ManageListingsPage/ManageListingsPage.js b/src/containers/ManageListingsPage/ManageListingsPage.js
index 2727b27b..6b1e2858 100644
--- a/src/containers/ManageListingsPage/ManageListingsPage.js
+++ b/src/containers/ManageListingsPage/ManageListingsPage.js
@@ -1,4 +1,4 @@
-import React, { PropTypes } from 'react';
+import React, { Component, PropTypes } from 'react';
import { compose } from 'redux';
import { connect } from 'react-redux';
import { FormattedMessage } from 'react-intl';
@@ -9,104 +9,139 @@ import { logout, authenticationInProgress } from '../../ducks/Auth.duck';
import { manageDisableScrolling, isScrollingDisabled } from '../../ducks/UI.duck';
import { ManageListingCard, PageLayout, PaginationLinks, Topbar, UserNav } from '../../components';
-import { getListingsById, queryOwnListings } from './ManageListingsPage.duck';
+import {
+ closeListing,
+ openListing,
+ getListingsById,
+ queryOwnListings,
+} from './ManageListingsPage.duck';
import css from './ManageListingsPage.css';
// Pagination page size might need to be dynamic on responsive page layouts
-// Current design has max 3 columns 96 is divisible by 2 and 3
+// Current design has max 3 columns 42 is divisible by 2 and 3
// So, there's enough cards to fill all columns on full pagination pages
-const RESULT_PAGE_SIZE = 96;
+const RESULT_PAGE_SIZE = 42;
-export const ManageListingsPageComponent = props => {
- const {
- authInfoError,
- authInProgress,
- currentUser,
- currentUserHasListings,
- history,
- isAuthenticated,
- listings,
- location,
- logoutError,
- notificationCount,
- onLogout,
- onManageDisableScrolling,
- pagination,
- queryInProgress,
- queryListingsError,
- queryParams,
- } = props;
+export class ManageListingsPageComponent extends Component {
+ constructor(props) {
+ super(props);
- const hasPaginationInfo = !!pagination && pagination.totalItems != null;
- const listingsAreLoaded = !queryInProgress && hasPaginationInfo;
+ this.state = { listingMenuOpen: null };
+ this.onToggleMenu = this.onToggleMenu.bind(this);
+ }
- const loadingResults = (
-
-
-
- );
+ onToggleMenu(listing) {
+ this.setState({ listingMenuOpen: listing });
+ }
- const noResults = (
-
-
-
- );
+ render() {
+ const {
+ authInfoError,
+ authInProgress,
+ currentUser,
+ currentUserHasListings,
+ history,
+ isAuthenticated,
+ listings,
+ location,
+ logoutError,
+ notificationCount,
+ onCloseListing,
+ onOpenListing,
+ onLogout,
+ onManageDisableScrolling,
+ pagination,
+ queryInProgress,
+ queryListingsError,
+ queryParams,
+ openingListing,
+ closingListing,
+ } = this.props;
- const queryError = (
-
-
-
- );
+ // TODO Handle openingListingError, closingListingError,
- const title = listingsAreLoaded
- ?
-
+
+
+ );
+
+ const queryError = (
+
+
+
+ );
+
+ const noResults = listingsAreLoaded && pagination.totalItems === 0
+ ?
+
+
+ : null;
+
+ const title = listingsAreLoaded && pagination.totalItems > 0
+ ?
+
+
+ : noResults;
+
+ const page = queryParams ? queryParams.page : 1;
+ const paginationLinks = listingsAreLoaded && pagination && pagination.totalPages > 1
+ ?
-
- : null;
- const page = queryParams ? queryParams.page : 1;
- const paginationLinks = listingsAreLoaded && pagination && pagination.totalPages > 1
- ?
- : null;
+ : null;
- return (
-
-
-
- {queryInProgress ? loadingResults : null}
- {queryListingsError ? queryError : null}
- {listingsAreLoaded && pagination.totalItems === 0 ? noResults : null}
-
- {title}
-
- {listings.map(l => (
-
- ))}
+ const listingMenuOpen = this.state.listingMenuOpen;
+
+ return (
+
+
+
+ {queryInProgress ? loadingResults : null}
+ {queryListingsError ? queryError : null}
+
+ {title}
+
+ {listings.map(l => (
+
+ ))}
+
+ {paginationLinks}
- {paginationLinks}
-
-
- );
-};
+
+ );
+ }
+}
ManageListingsPageComponent.defaultProps = {
authInfoError: null,
@@ -117,9 +152,11 @@ ManageListingsPageComponent.defaultProps = {
pagination: null,
queryListingsError: null,
queryParams: null,
+ closingListing: null,
+ openingListing: null,
};
-const { arrayOf, bool, func, instanceOf, number, object, shape } = PropTypes;
+const { arrayOf, bool, func, instanceOf, number, object, shape, string } = PropTypes;
ManageListingsPageComponent.propTypes = {
authInfoError: instanceOf(Error),
@@ -130,12 +167,16 @@ ManageListingsPageComponent.propTypes = {
listings: arrayOf(propTypes.ownListing),
logoutError: instanceOf(Error),
notificationCount: number,
+ onCloseListing: func.isRequired,
+ onOpenListing: func.isRequired,
onLogout: func.isRequired,
onManageDisableScrolling: func.isRequired,
pagination: propTypes.pagination,
queryInProgress: bool.isRequired,
queryListingsError: instanceOf(Error),
queryParams: object,
+ closingListing: shape({ uuid: string.isRequired }),
+ openingListing: shape({ uuid: string.isRequired }),
// from withRouter
history: shape({
@@ -151,6 +192,10 @@ const mapStateToProps = state => {
queryInProgress,
queryListingsError,
queryParams,
+ openingListing,
+ openingListingError,
+ closingListing,
+ closingListingError,
} = state.ManageListingsPage;
const listings = getListingsById(state, currentPageResultIds);
// PageLayout needs authInfoError and logoutError, Topbar needs isAuthenticated
@@ -176,10 +221,16 @@ const mapStateToProps = state => {
queryListingsError,
queryParams,
scrollingDisabled: isScrollingDisabled(state),
+ openingListing,
+ openingListingError,
+ closingListing,
+ closingListingError,
};
};
const mapDispatchToProps = dispatch => ({
+ onCloseListing: listingId => dispatch(closeListing(listingId)),
+ onOpenListing: listingId => dispatch(openListing(listingId)),
onLogout: historyPush => dispatch(logout(historyPush)),
onManageDisableScrolling: (componentId, disableScrolling) =>
dispatch(manageDisableScrolling(componentId, disableScrolling)),
diff --git a/src/containers/ManageListingsPage/ManageListingsPage.test.js b/src/containers/ManageListingsPage/ManageListingsPage.test.js
index df95b218..9fe88dc8 100644
--- a/src/containers/ManageListingsPage/ManageListingsPage.test.js
+++ b/src/containers/ManageListingsPage/ManageListingsPage.test.js
@@ -18,6 +18,8 @@ describe('ContactDetailsPage', () => {
isAuthenticated={false}
onLogout={noop}
onManageDisableScrolling={noop}
+ onCloseListing={noop}
+ onOpenListing={noop}
/>
);
expect(tree).toMatchSnapshot();
diff --git a/src/translations/en.json b/src/translations/en.json
index 183c1ba8..5bef0c89 100644
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -158,10 +158,14 @@
"LoginForm.passwordLabel": "Password",
"LoginForm.passwordPlaceholder": "Enter your password…",
"LoginForm.passwordRequired": "This field is required",
+ "ManageListingCard.closedListing": "This listing is closed and hidden from the marketplace.",
+ "ManageListingCard.closeListing": "Close listing",
"ManageListingCard.edit": "Edit",
+ "ManageListingCard.openListing": "Open listing",
"ManageListingCard.perNight": "per night",
"ManageListingCard.unsupportedPrice": "({currency})",
"ManageListingCard.unsupportedPriceTitle": "Unsupported currency ({currency})",
+ "ManageListingCard.viewListing": "View listing",
"ManageListingsPage.accountSettings": "Account settings",
"ManageListingsPage.loadingOwnListings": "Loading listings…",
"ManageListingsPage.noResults": "You don't have any listings.",
diff --git a/yarn.lock b/yarn.lock
index 03845199..bf14069c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6242,9 +6242,9 @@ sharetribe-scripts@0.9.2:
optionalDependencies:
fsevents "1.0.17"
-"sharetribe-sdk@git+ssh://git@github.com/sharetribe/sharetribe-sdk-js#ec9fd243307ce21b95052535d2e358f681ed82b7":
+"sharetribe-sdk@git+ssh://git@github.com/sharetribe/sharetribe-sdk-js#75c5b1a9df5142a1066cd055c2157b410f4c4aee":
version "0.0.1"
- resolved "git+ssh://git@github.com/sharetribe/sharetribe-sdk-js#ec9fd243307ce21b95052535d2e358f681ed82b7"
+ resolved "git+ssh://git@github.com/sharetribe/sharetribe-sdk-js#75c5b1a9df5142a1066cd055c2157b410f4c4aee"
dependencies:
axios "^0.15.3"
js-cookie "^2.1.3"