Merge pull request #370 from sharetribe/managelistingspage-open-close-listing

Managelistingspage open close listing
This commit is contained in:
Vesa Luusua 2017-08-30 16:11:47 +03:00 committed by GitHub
commit 220cb38afa
17 changed files with 872 additions and 118 deletions

View file

@ -34,7 +34,7 @@
"redux-thunk": "^2.2.0",
"sanitize.css": "^5.0.0",
"sharetribe-scripts": "0.9.2",
"sharetribe-sdk": "git+ssh://git@github.com/sharetribe/sharetribe-sdk-js#ec9fd243307ce21b95052535d2e358f681ed82b7",
"sharetribe-sdk": "git+ssh://git@github.com/sharetribe/sharetribe-sdk-js#75c5b1a9df5142a1066cd055c2157b410f4c4aee",
"source-map-support": "^0.4.15",
"url": "^0.11.0"
},

View file

@ -1,9 +1,20 @@
@import '../../marketplace.css';
:root {
--coverEverything: {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
}
.root {
/* Layout */
display: flex;
flex-direction: column;
position: relative;
/* Remove link's hover effect */
&:hover {
@ -11,37 +22,176 @@
}
}
.menuOverlayWrapper {
/* Positioning */
@apply --coverEverything;
}
.menuOverlay {
/* Positioning */
@apply --coverEverything;
/* Overlay */
background-color: var(--matterColorAnti);
mix-blend-mode: multiply;
opacity: 0;
transition: var(--transitionStyleButton);
}
.menuOverlayContent {
/* Positioning */
@apply --coverEverything;
/* Fonts */
@apply --marketplaceH4FontStyles;
font-weight: var(--fontWeightSemiBold);
color: var(--matterColorLight);
/* Layout */
display: flex;
justify-content: center;
align-items: center;
margin-top: 0;
margin-bottom: 0;
opacity: 0;
@media (--viewportMedium) {
font-weight: var(--fontWeightSemiBold);
margin-top: 0;
margin-bottom: 0;
}
}
.threeToTwoWrapper {
/* Layout */
display: block;
width: 100%;
position: relative;
&:hover .menuOverlay {
opacity: 0.8;
}
&:hover .menuOverlayContent {
opacity: 1.0;
}
}
.menuOverlayOpen {
& .menuOverlay {
opacity: 0.8;
}
& .menuOverlayContent {
opacity: 0.8;
}
}
/* Firefox doesn't support image aspect ratio inside flexbox */
.aspectWrapper {
padding-bottom: 66.6667%; /* 3:2 Aspect Ratio */
background: var(--matterColorNegative); /* Loading BG color */
transition: var(--transitionStyleButton);
&:hover {
transform: scale(1.02);
box-shadow: var(--boxShadowListingCard);
transition: var(--transitionStyleButton);
}
}
.rootForImage {
/* Layout - image will take space defined by aspect ratio wrapper */
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
@apply --coverEverything;
width: 100%;
border-radius: 2px;
}
.menubarWrapper {
/* Position */
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 42px;
}
.menubarGradient {
/* Position */
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
/* Gradient */
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAAqCAYAAACUV/o4AAAABGdBTUEAALGPC/xhBQAAAAlwSFlzAAALEwAACxMBAJqcGAAAAVlpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IlhNUCBDb3JlIDUuNC4wIj4KICAgPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICAgICAgPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIKICAgICAgICAgICAgeG1sbnM6dGlmZj0iaHR0cDovL25zLmFkb2JlLmNvbS90aWZmLzEuMC8iPgogICAgICAgICA8dGlmZjpPcmllbnRhdGlvbj4xPC90aWZmOk9yaWVudGF0aW9uPgogICAgICA8L3JkZjpEZXNjcmlwdGlvbj4KICAgPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KTMInWQAAAGhJREFUKBVjZGBgKANibiAGga8sQAKEf4N4QMAC4vwH4r8gHgiABOAcmMAfEAMGsKrA0DIqAAsvHGFKkwACRRQjzGJQRP2DcUA0LOZAEQ5S9R8mAJIEA6wCyIYSqYVSz4FcxwR1E5gCAD7HLabS/HFUAAAAAElFTkSuQmCC');
background-repeat: repeat;
opacity: 0.5;
}
.menubar {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: flex-end;
}
.menuLabel {
padding: 8px 8px 0 8px;
}
.iconWrapper {
padding: 2px 10px 8px 10px;
color: var(--matterColorLight);
border-radius: 2px 2px 0 0;
}
.listingMenuIsOpen {
& .iconWrapper {
background-color: var(--matterColorLight);
color: var(--matterColor);
}
}
.menuContent {
visibility:hidden;
opacity: 0;
pointer-events: none;
position: absolute;
z-index: var(--zIndexPopup);
background-color: var(--matterColorLight);
border-radius: 2px 0 2px 2px;
}
.menuItem {
@apply --marketplaceH4FontStyles;
color: var(--matterColor);
white-space: nowrap;
text-align: left;
min-width: 175px;
padding: 8px 14px;
/* Remove default margins from font */
margin-top: 0;
margin-bottom: 0;
&:hover {
color: var(--failColor);
text-decoration: none;
}
@media (--viewportMedium) {
margin-top: 0;
margin-bottom: 0;
}
}
.menuItemDisabled {
color: var(--matterColorNegative);
&:hover {
color: var(--matterColorNegative);
}
}
.info {
/* Layout */
display: flex;
@ -125,3 +275,57 @@
background-repeat: no-repeat;
background-position: 15px center;
}
.loadingOverlayWrapper,
.closedOverlayWrapper {
/* Positioning */
@apply --coverEverything;
}
.loadingOverlay,
.closedOverlay {
/* Positioning */
@apply --coverEverything;
/* Overlay background style */
background-color: var(--matterColorLight);
opacity: 0.9;
}
.loadingOverlayContent,
.closedOverlayContent {
@apply --marketplaceTinyFontStyles;
color: var(--matterColor);
/* Positioning */
@apply --coverEverything;
/* Layout */
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
cursor: default;
/* Remove default margins from font */
margin-top: 0;
margin-bottom: 0;
@media (--viewportMedium) {
margin-top: 0;
margin-bottom: 0;
}
}
.closedMessage {
max-width: 180px;
text-align: center;
margin-bottom: 16px;
}
.openListingButton {
@apply --marketplaceH5FontStyles;
width: 114px;
height: 41px;
padding: 8px 0;
}

View file

@ -3,6 +3,7 @@ import React from 'react';
import ManageListingCard from './ManageListingCard';
import { createOwnListing, fakeIntl } from '../../util/test-data';
const noop = () => null;
const listing = { ...createOwnListing('listing1') };
const ManageListingCardWrapper = props => (
@ -16,5 +17,11 @@ export const ManageListingCardWrapped = {
props: {
intl: fakeIntl,
listing,
isMenuOpen: false,
onCloseListing: noop,
onOpenListing: noop,
onToggleMenu: noop,
flattenedRoutes: [],
history: { push: noop },
},
};

View file

@ -9,11 +9,26 @@ import { ensureListing } from '../../util/data';
import { createSlug } from '../../util/urlHelpers';
import { withFlattenedRoutes } from '../../util/contextHelpers';
import { createResourceLocatorString } from '../../util/routes';
import { NamedLink, SecondaryButton, ResponsiveImage } from '../../components';
import {
InlineTextButton,
Menu,
MenuLabel,
MenuContent,
MenuItem,
NamedLink,
PrimaryButton,
SecondaryButton,
SpinnerIcon,
ResponsiveImage,
} from '../../components';
import config from '../../config';
import MenuIcon from './MenuIcon';
import css from './ManageListingCard.css';
// Menu content needs the same padding
const MENU_CONTENT_OFFSET = 8;
const priceData = (price, intl) => {
if (price && price.currency === config.currency) {
const formattedPrice = formatMoney(intl, price);
@ -48,20 +63,77 @@ export const ManageListingCardComponent = props => {
flattenedRoutes,
history,
intl,
isMenuOpen,
actionsInProgressListingId,
listing,
onCloseListing,
onOpenListing,
onToggleMenu,
} = props;
const classes = classNames(rootClassName || css.root, className);
const currentListing = ensureListing(listing);
const id = currentListing.id.uuid;
const { title = '', price } = currentListing.attributes;
const { title = '', price, open } = currentListing.attributes;
const slug = createSlug(title);
const firstImage = currentListing.images && currentListing.images.length > 0
? currentListing.images[0]
: null;
const menuItemClasses = classNames(css.menuItem, {
[css.menuItemDisabled]: !!actionsInProgressListingId,
});
// TODO: Currently, API can return currencies that are not supported by starter app.
const { formattedPrice, priceTitle } = priceData(price, intl);
/* eslint-disable jsx-a11y/no-static-element-interactions */
const closedOverlay = open
? null
: <div
className={css.closedOverlayWrapper}
onClick={event => {
event.preventDefault();
event.stopPropagation();
}}
>
<div className={css.closedOverlay} />
<div className={css.closedOverlayContent}>
<div className={css.closedMessage}>
<FormattedMessage id="ManageListingCard.closedListing" />
</div>
<PrimaryButton
className={css.openListingButton}
disabled={!!actionsInProgressListingId}
onClick={event => {
event.preventDefault();
event.stopPropagation();
if (!actionsInProgressListingId) {
onOpenListing(currentListing.id);
}
}}
>
<FormattedMessage id="ManageListingCard.openListing" />
</PrimaryButton>
</div>
</div>;
const thisInProgress = actionsInProgressListingId && actionsInProgressListingId.uuid === id;
const loadingOrClosedOverlay = thisInProgress
? <div
className={css.loadingOverlayWrapper}
onClick={event => {
event.preventDefault();
event.stopPropagation();
}}
>
<div className={css.loadingOverlay} />
<div className={css.loadingOverlayContent}>
<SpinnerIcon />
</div>
</div>
: closedOverlay;
/* eslint-enable jsx-a11y/no-static-element-interactions */
return (
<NamedLink className={classes} name="ListingPage" params={{ id, slug }}>
<div className={css.threeToTwoWrapper}>
@ -76,6 +148,50 @@ export const ManageListingCardComponent = props => {
]}
/>
</div>
<div className={classNames(css.menuOverlayWrapper, { [css.menuOverlayOpen]: isMenuOpen })}>
<div className={classNames(css.menuOverlay)} />
<div className={css.menuOverlayContent}>
<FormattedMessage id="ManageListingCard.viewListing" />
</div>
</div>
<div className={css.menubarWrapper}>
<div className={css.menubarGradient} />
<div className={css.menubar}>
<Menu
contentPlacementOffset={MENU_CONTENT_OFFSET}
contentPosition="left"
useArrow={false}
onToggleActive={isOpen => {
const listingOpen = isOpen ? currentListing : null;
onToggleMenu(listingOpen);
}}
isOpen={isMenuOpen}
>
<MenuLabel className={css.menuLabel} isOpenClassName={css.listingMenuIsOpen}>
<div className={css.iconWrapper}>
<MenuIcon className={css.menuIcon} isActive={isMenuOpen} />
</div>
</MenuLabel>
<MenuContent rootClassName={css.menuContent}>
<MenuItem key="close-listing">
<InlineTextButton
className={menuItemClasses}
onClick={event => {
event.preventDefault();
event.stopPropagation();
if (!actionsInProgressListingId) {
onToggleMenu(null);
onCloseListing(currentListing.id);
}
}}
>
<FormattedMessage id="ManageListingCard.closeListing" />
</InlineTextButton>
</MenuItem>
</MenuContent>
</Menu>
</div>
</div>
</div>
<div className={css.info}>
<div className={css.price}>
@ -102,6 +218,7 @@ export const ManageListingCardComponent = props => {
<FormattedMessage id="ManageListingCard.edit" />
</SecondaryButton>
</div>
{loadingOrClosedOverlay}
</NamedLink>
);
};
@ -109,15 +226,21 @@ export const ManageListingCardComponent = props => {
ManageListingCardComponent.defaultProps = {
className: null,
rootClassName: null,
actionsInProgressListingId: null,
};
const { arrayOf, func, shape, string } = PropTypes;
const { arrayOf, bool, func, shape, string } = PropTypes;
ManageListingCardComponent.propTypes = {
className: string,
rootClassName: string,
intl: intlShape.isRequired,
listing: propTypes.listing.isRequired,
isMenuOpen: bool.isRequired,
actionsInProgressListingId: shape({ uuid: string.isRequired }),
onCloseListing: func.isRequired,
onOpenListing: func.isRequired,
onToggleMenu: func.isRequired,
// from withFlattenedRoutes
flattenedRoutes: arrayOf(propTypes.route).isRequired,

View file

@ -14,6 +14,10 @@ describe('ManageListingCard', () => {
history={{ push: noop }}
listing={listing}
intl={fakeIntl}
isMenuOpen={false}
onCloseListing={noop}
onOpenListing={noop}
onToggleMenu={noop}
/>
);
expect(tree).toMatchSnapshot();

View file

@ -0,0 +1,54 @@
import React, { PropTypes } from 'react';
import classNames from 'classnames';
import css from './ManageListingCard.css';
const MenuIcon = props => {
const { className, isActive } = props;
const classes = classNames(css.menuIcon, className);
const filter = isActive ? '' : 'url(#a)';
return (
<svg
className={classes}
width="26"
height="12"
viewBox="0 0 26 12"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<filter
x="-38.9%"
y="-125%"
width="177.8%"
height="450%"
filterUnits="objectBoundingBox"
id="a"
>
<feOffset dy="2" in="SourceAlpha" result="shadowOffsetOuter1" />
<feGaussianBlur stdDeviation="2" in="shadowOffsetOuter1" result="shadowBlurOuter1" />
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.5 0" in="shadowBlurOuter1" />
<feBlend in="SourceGraphic" in2="blurOut" mode="normal" />
</filter>
</defs>
<g transform="translate(-342 -18)" filter={filter}>
<path
d="M348 24c-1.105 0-2-.895-2-2s.895-2 2-2 2 .895 2 2-.895 2-2 2zm7 0c-1.105 0-2-.895-2-2s.895-2 2-2 2 .895 2 2-.895 2-2 2zm7 0c-1.105 0-2-.895-2-2s.895-2 2-2 2 .895 2 2-.895 2-2 2z"
/>
</g>
</svg>
);
};
MenuIcon.defaultProps = {
className: null,
isActive: false,
};
const { bool, string } = PropTypes;
MenuIcon.propTypes = {
className: string,
isActive: bool,
};
export default MenuIcon;

View file

@ -30,6 +30,62 @@ exports[`ManageListingCard matches snapshot 1`] = `
rootClassName={null}
sizes={null} />
</div>
<div
className="">
<div
className="" />
<div>
<FormattedMessage
id="ManageListingCard.viewListing"
values={Object {}} />
</div>
</div>
<div>
<div />
<div>
<Menu
className={null}
contentPlacementOffset={8}
contentPosition="left"
isOpen={false}
onToggleActive={[Function]}
rootClassName=""
useArrow={false}>
<MenuLabel
className={null}
isOpen={false}
isOpenClassName={null}
onToggleActive={null}
rootClassName="">
<div>
<MenuIcon
className={null}
isActive={false} />
</div>
</MenuLabel>
<MenuContent
arrowPosition={null}
className={null}
contentClassName={null}
contentRef={null}
isOpen={false}
rootClassName=""
style={null}>
<MenuItem
className={null}
rootClassName="">
<InlineTextButton
className=""
onClick={[Function]}>
<FormattedMessage
id="ManageListingCard.closeListing"
values={Object {}} />
</InlineTextButton>
</MenuItem>
</MenuContent>
</Menu>
</div>
</div>
</div>
<div>
<div>

View file

@ -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 <div className="foo" onClick={() => {}} 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;

View file

@ -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

View file

@ -0,0 +1,7 @@
@import '../../marketplace.css';
.root {
width: 100px;
height: 100px;
stroke: var(--marketplaceColor);
}

View file

@ -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 (
<svg
className={classes}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 100 100"
preserveAspectRatio="xMidYMid"
>
<circle cx="50" cy="50" fill="none" r="10" strokeWidth="3" strokeLinecap="round">
<animateTransform
attributeName="transform"
type="rotate"
calcMode="linear"
values="0 50 50;180 50 50;720 50 50"
keyTimes="0;0.5;1"
dur="1s"
begin="0s"
repeatCount="indefinite"
/>
<animate
attributeName="stroke-dasharray"
calcMode="linear"
values="6 56;46 14;6 56"
keyTimes="0;0.5;1"
dur="1"
begin="0s"
repeatCount="indefinite"
/>
</circle>
</svg>
);
};
SpinnerIcon.defaultProps = {
rootClassName: null,
className: null,
};
const { string } = PropTypes;
SpinnerIcon.propTypes = {
rootClassName: string,
className: string,
};
export default SpinnerIcon;

View file

@ -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,

View file

@ -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));
});
};

View file

@ -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 = (
<h2>
<FormattedMessage id="ManageListingsPage.loadingOwnListings" />
</h2>
);
onToggleMenu(listing) {
this.setState({ listingMenuOpen: listing });
}
const noResults = (
<h2>
<FormattedMessage id="ManageListingsPage.noResults" />
</h2>
);
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 = (
<h2 className={css.error}>
<FormattedMessage id="ManageListingsPage.queryError" />
</h2>
);
// TODO Handle openingListingError, closingListingError,
const title = listingsAreLoaded
? <h1 className={css.title}>
<FormattedMessage
id="ManageListingsPage.youHaveListings"
values={{ count: pagination.totalItems }}
const hasPaginationInfo = !!pagination && pagination.totalItems != null;
const listingsAreLoaded = !queryInProgress && hasPaginationInfo;
const loadingResults = (
<h2>
<FormattedMessage id="ManageListingsPage.loadingOwnListings" />
</h2>
);
const queryError = (
<h2 className={css.error}>
<FormattedMessage id="ManageListingsPage.queryError" />
</h2>
);
const noResults = listingsAreLoaded && pagination.totalItems === 0
? <h1 className={css.title}>
<FormattedMessage id="ManageListingsPage.noResults" />
</h1>
: null;
const title = listingsAreLoaded && pagination.totalItems > 0
? <h1 className={css.title}>
<FormattedMessage
id="ManageListingsPage.youHaveListings"
values={{ count: pagination.totalItems }}
/>
</h1>
: noResults;
const page = queryParams ? queryParams.page : 1;
const paginationLinks = listingsAreLoaded && pagination && pagination.totalPages > 1
? <PaginationLinks
className={css.pagination}
pageName="ManageListingsPage"
pageSearchParams={{ page }}
pagination={pagination}
/>
</h1>
: null;
const page = queryParams ? queryParams.page : 1;
const paginationLinks = listingsAreLoaded && pagination && pagination.totalPages > 1
? <PaginationLinks
className={css.pagination}
pageName="ManageListingsPage"
pageSearchParams={{ page }}
pagination={pagination}
/>
: null;
: null;
return (
<PageLayout authInfoError={authInfoError} logoutError={logoutError} title="Manage listings">
<Topbar
authInProgress={authInProgress}
currentUser={currentUser}
currentUserHasListings={currentUserHasListings}
currentPage="ManageListingsPage"
history={history}
isAuthenticated={isAuthenticated}
location={location}
notificationCount={notificationCount}
onLogout={onLogout}
onManageDisableScrolling={onManageDisableScrolling}
/>
<UserNav selectedPageName="ManageListingsPage" />
{queryInProgress ? loadingResults : null}
{queryListingsError ? queryError : null}
{listingsAreLoaded && pagination.totalItems === 0 ? noResults : null}
<div className={css.listingPanel}>
{title}
<div className={css.listingCards}>
{listings.map(l => (
<ManageListingCard className={css.listingCard} key={l.id.uuid} listing={l} />
))}
const listingMenuOpen = this.state.listingMenuOpen;
return (
<PageLayout authInfoError={authInfoError} logoutError={logoutError} title="Manage listings">
<Topbar
authInProgress={authInProgress}
currentUser={currentUser}
currentUserHasListings={currentUserHasListings}
currentPage="ManageListingsPage"
history={history}
isAuthenticated={isAuthenticated}
location={location}
notificationCount={notificationCount}
onLogout={onLogout}
onManageDisableScrolling={onManageDisableScrolling}
/>
<UserNav selectedPageName="ManageListingsPage" />
{queryInProgress ? loadingResults : null}
{queryListingsError ? queryError : null}
<div className={css.listingPanel}>
{title}
<div className={css.listingCards}>
{listings.map(l => (
<ManageListingCard
className={css.listingCard}
key={l.id.uuid}
listing={l}
isMenuOpen={!!listingMenuOpen && listingMenuOpen.id.uuid === l.id.uuid}
actionsInProgressListingId={openingListing || closingListing}
onToggleMenu={this.onToggleMenu}
onCloseListing={onCloseListing}
onOpenListing={onOpenListing}
/>
))}
</div>
{paginationLinks}
</div>
{paginationLinks}
</div>
</PageLayout>
);
};
</PageLayout>
);
}
}
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)),

View file

@ -18,6 +18,8 @@ describe('ContactDetailsPage', () => {
isAuthenticated={false}
onLogout={noop}
onManageDisableScrolling={noop}
onCloseListing={noop}
onOpenListing={noop}
/>
);
expect(tree).toMatchSnapshot();

View file

@ -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.",

View file

@ -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"