From caad06353e5d1c036982c2f0a43bf9195e407d53 Mon Sep 17 00:00:00 2001 From: Vesa Luusua Date: Mon, 28 Jan 2019 18:43:49 +0200 Subject: [PATCH] Add state graph description and refactor transaction.js functions to use it. --- src/components/ActivityFeed/ActivityFeed.js | 6 +- .../BookingBreakdown/LineItemTotalPrice.js | 6 +- .../TransactionPanel/TransactionPanel.css | 8 + .../TransactionPanel/TransactionPanel.js | 4 +- src/containers/InboxPage/InboxPage.js | 4 +- .../TransactionPage/TransactionPage.duck.js | 4 +- src/util/transaction.js | 304 +++++++++++++----- src/util/transaction.test.js | 96 ++++++ 8 files changed, 337 insertions(+), 95 deletions(-) create mode 100644 src/util/transaction.test.js diff --git a/src/components/ActivityFeed/ActivityFeed.js b/src/components/ActivityFeed/ActivityFeed.js index e92f7dad..8087e9a2 100644 --- a/src/components/ActivityFeed/ActivityFeed.js +++ b/src/components/ActivityFeed/ActivityFeed.js @@ -20,7 +20,7 @@ import { TRANSITION_REVIEW_2_BY_PROVIDER, TX_TRANSITION_ACTOR_CUSTOMER, TX_TRANSITION_ACTOR_PROVIDER, - areReviewsCompleted, + transitionIsReviewed, } from '../../util/transaction'; import { propTypes } from '../../util/types'; import * as log from '../../util/log'; @@ -122,7 +122,7 @@ const hasUserLeftAReviewFirst = (userRole, lastTransition) => { userRole === TX_TRANSITION_ACTOR_CUSTOMER) || (lastTransition === TRANSITION_REVIEW_1_BY_PROVIDER && userRole === TX_TRANSITION_ACTOR_PROVIDER) || - areReviewsCompleted(lastTransition) + transitionIsReviewed(lastTransition) ); }; @@ -267,7 +267,7 @@ const Transition = props => { let reviewComponent = null; - if (isReviewTransition(currentTransition) && areReviewsCompleted(lastTransition)) { + if (isReviewTransition(currentTransition) && transitionIsReviewed(lastTransition)) { const customerReview = currentTransition === TRANSITION_REVIEW_1_BY_CUSTOMER || currentTransition === TRANSITION_REVIEW_2_BY_CUSTOMER; diff --git a/src/components/BookingBreakdown/LineItemTotalPrice.js b/src/components/BookingBreakdown/LineItemTotalPrice.js index 92099050..c55a036d 100644 --- a/src/components/BookingBreakdown/LineItemTotalPrice.js +++ b/src/components/BookingBreakdown/LineItemTotalPrice.js @@ -2,7 +2,7 @@ import React from 'react'; import { bool } from 'prop-types'; import { FormattedMessage, intlShape } from 'react-intl'; import { formatMoney } from '../../util/currency'; -import { txIsCanceled, txIsCompleted, txIsDeclinedOrExpired } from '../../util/transaction'; +import { txIsCanceled, txIsDelivered, txIsDeclined } from '../../util/transaction'; import { propTypes } from '../../util/types'; import css from './BookingBreakdown.css'; @@ -11,9 +11,9 @@ const LineItemUnitPrice = props => { const { transaction, isProvider, intl } = props; let providerTotalMessageId = 'BookingBreakdown.providerTotalDefault'; - if (txIsCompleted(transaction)) { + if (txIsDelivered(transaction)) { providerTotalMessageId = 'BookingBreakdown.providerTotalDelivered'; - } else if (txIsDeclinedOrExpired(transaction)) { + } else if (txIsDeclined(transaction)) { providerTotalMessageId = 'BookingBreakdown.providerTotalDeclined'; } else if (txIsCanceled(transaction)) { providerTotalMessageId = 'BookingBreakdown.providerTotalCanceled'; diff --git a/src/components/TransactionPanel/TransactionPanel.css b/src/components/TransactionPanel/TransactionPanel.css index 3c71b6fa..6403385a 100644 --- a/src/components/TransactionPanel/TransactionPanel.css +++ b/src/components/TransactionPanel/TransactionPanel.css @@ -285,6 +285,14 @@ } } +.breakdownContainer { + display: none; + + @media (--viewportLarge) { + display: block; + } +} + /* FeedSection subcomponent */ .feedHeading { color: var(--matterColorAnti); diff --git a/src/components/TransactionPanel/TransactionPanel.js b/src/components/TransactionPanel/TransactionPanel.js index b402bcc3..b59b0ad5 100644 --- a/src/components/TransactionPanel/TransactionPanel.js +++ b/src/components/TransactionPanel/TransactionPanel.js @@ -5,7 +5,7 @@ import classNames from 'classnames'; import { txIsAccepted, txIsCanceled, - txIsDeclinedOrExpired, + txIsDeclined, txIsEnquired, txIsRequested, txHasBeenDelivered, @@ -203,7 +203,7 @@ export class TransactionPanelComponent extends Component { showDetailCardHeadings: isCustomer, showAddress: isCustomer, }; - } else if (txIsDeclinedOrExpired(tx)) { + } else if (txIsDeclined(tx)) { return { headingState: HEADING_DECLINED, showDetailCardHeadings: isCustomer, diff --git a/src/containers/InboxPage/InboxPage.js b/src/containers/InboxPage/InboxPage.js index f0e95c16..6d5544ab 100644 --- a/src/containers/InboxPage/InboxPage.js +++ b/src/containers/InboxPage/InboxPage.js @@ -8,7 +8,7 @@ import classNames from 'classnames'; import { txIsAccepted, txIsCanceled, - txIsDeclinedOrExpired, + txIsDeclined, txIsEnquired, txIsRequested, txHasBeenDelivered, @@ -88,7 +88,7 @@ export const txState = (intl, tx, type) => { }; return requested; - } else if (txIsDeclinedOrExpired(tx)) { + } else if (txIsDeclined(tx)) { return { nameClassName: css.nameNotEmphasized, bookingClassName: css.bookingNoActionNeeded, diff --git a/src/containers/TransactionPage/TransactionPage.duck.js b/src/containers/TransactionPage/TransactionPage.duck.js index ac59f84e..903777bf 100644 --- a/src/containers/TransactionPage/TransactionPage.duck.js +++ b/src/containers/TransactionPage/TransactionPage.duck.js @@ -9,7 +9,7 @@ import { txIsEnquired, getReview1Transition, getReview2Transition, - txHasFirstReviewBy, + txIsInFirstReviewBy, TRANSITION_ACCEPT, TRANSITION_DECLINE, } from '../../util/transaction'; @@ -495,7 +495,7 @@ const sendReviewAsFirst = (id, params, role, dispatch, sdk) => { export const sendReview = (role, tx, reviewRating, reviewContent) => (dispatch, getState, sdk) => { const params = { reviewRating, reviewContent }; - const txStateOtherPartyFirst = txHasFirstReviewBy(tx, role !== CUSTOMER); + const txStateOtherPartyFirst = txIsInFirstReviewBy(tx, role !== CUSTOMER); dispatch(sendReviewRequest()); diff --git a/src/util/transaction.js b/src/util/transaction.js index 42e6c3ee..694b209c 100644 --- a/src/util/transaction.js +++ b/src/util/transaction.js @@ -1,5 +1,14 @@ import { ensureTransaction } from './data'; +/** + * Transitions + * + * These strings must sync with values defined in Flex API, + * since transaction objects given by API contain info about last transitions. + * All the actions in API side happen in transitions, + * so we need to understand what those strings mean. + */ + // When a customer makes a booking to a listing, a transaction is // created with the initial request transition. export const TRANSITION_REQUEST = 'transition/request'; @@ -34,89 +43,11 @@ export const TRANSITION_EXPIRE_CUSTOMER_REVIEW_PERIOD = 'transition/expire-custo export const TRANSITION_EXPIRE_PROVIDER_REVIEW_PERIOD = 'transition/expire-provider-review-period'; export const TRANSITION_EXPIRE_REVIEW_PERIOD = 'transition/expire-review-period'; -export const TRANSITIONS = [ - TRANSITION_ACCEPT, - TRANSITION_CANCEL, - TRANSITION_COMPLETE, - TRANSITION_DECLINE, - TRANSITION_ENQUIRE, - TRANSITION_EXPIRE, - TRANSITION_EXPIRE_CUSTOMER_REVIEW_PERIOD, - TRANSITION_EXPIRE_PROVIDER_REVIEW_PERIOD, - TRANSITION_EXPIRE_REVIEW_PERIOD, - TRANSITION_REQUEST, - TRANSITION_REQUEST_AFTER_ENQUIRY, - TRANSITION_REVIEW_1_BY_CUSTOMER, - TRANSITION_REVIEW_1_BY_PROVIDER, - TRANSITION_REVIEW_2_BY_CUSTOMER, - TRANSITION_REVIEW_2_BY_PROVIDER, -]; - -const txLastTransition = tx => ensureTransaction(tx).attributes.lastTransition; - -export const txIsEnquired = tx => txLastTransition(tx) === TRANSITION_ENQUIRE; - -export const transitionsToRequested = [TRANSITION_REQUEST, TRANSITION_REQUEST_AFTER_ENQUIRY]; -export const txIsRequested = tx => transitionsToRequested.includes(txLastTransition(tx)); - -export const txIsAccepted = tx => txLastTransition(tx) === TRANSITION_ACCEPT; - -export const txIsDeclined = tx => txLastTransition(tx) === TRANSITION_DECLINE; - -export const txIsExpired = tx => txLastTransition(tx) === TRANSITION_EXPIRE; - -export const txIsDeclinedOrExpired = tx => txIsDeclined(tx) || txIsExpired(tx); - -export const txIsCanceled = tx => txLastTransition(tx) === TRANSITION_CANCEL; - -export const txIsCompleted = tx => txLastTransition(tx) === TRANSITION_COMPLETE; - -const firstReviewTransitions = [TRANSITION_REVIEW_1_BY_CUSTOMER, TRANSITION_REVIEW_1_BY_PROVIDER]; -export const txHasFirstReview = tx => firstReviewTransitions.includes(txLastTransition(tx)); - -export const txHasFirstReviewBy = (tx, isCustomer) => - isCustomer - ? txLastTransition(tx) === TRANSITION_REVIEW_1_BY_CUSTOMER - : txLastTransition(tx) === TRANSITION_REVIEW_1_BY_PROVIDER; - -export const txIsReviewed = tx => areReviewsCompleted(txLastTransition(tx)); - -export const getReview1Transition = isCustomer => - isCustomer ? TRANSITION_REVIEW_1_BY_CUSTOMER : TRANSITION_REVIEW_1_BY_PROVIDER; - -export const getReview2Transition = isCustomer => - isCustomer ? TRANSITION_REVIEW_2_BY_CUSTOMER : TRANSITION_REVIEW_2_BY_PROVIDER; - -// Check if tx transition is followed by a state where -// reviews are completed -export const areReviewsCompleted = transition => { - return [ - TRANSITION_EXPIRE_CUSTOMER_REVIEW_PERIOD, - TRANSITION_EXPIRE_PROVIDER_REVIEW_PERIOD, - TRANSITION_EXPIRE_REVIEW_PERIOD, - TRANSITION_REVIEW_2_BY_CUSTOMER, - TRANSITION_REVIEW_2_BY_PROVIDER, - ].includes(transition); -}; - -export const txHasBeenAccepted = tx => { - const transition = txLastTransition(tx); - return [ - TRANSITION_ACCEPT, - TRANSITION_REVIEW_1_BY_CUSTOMER, - TRANSITION_REVIEW_1_BY_PROVIDER, - TRANSITION_REVIEW_2_BY_CUSTOMER, - TRANSITION_REVIEW_2_BY_PROVIDER, - TRANSITION_EXPIRE_CUSTOMER_REVIEW_PERIOD, - TRANSITION_EXPIRE_PROVIDER_REVIEW_PERIOD, - TRANSITION_EXPIRE_REVIEW_PERIOD, - TRANSITION_COMPLETE, - ].includes(transition); -}; - -export const txHasBeenDelivered = tx => { - return txHasFirstReview(tx) || txIsReviewed(tx) || txIsCompleted(tx); -}; +/** + * Actors + * + * There are 4 different actors that might initiate transitions: + */ // Roles of actors that perform transaction transitions export const TX_TRANSITION_ACTOR_CUSTOMER = 'customer'; @@ -130,3 +61,210 @@ export const TX_TRANSITION_ACTORS = [ TX_TRANSITION_ACTOR_SYSTEM, TX_TRANSITION_ACTOR_OPERATOR, ]; + +/** + * States + * + * These constants are only for making it clear how transitions work together. + * You should not use these constants outside of this file. + * + * Note: these states are not in sync with states used transaction process definitions + * in Marketplace API. Only last transitions are passed along transaction object. + */ +const STATE_INITIAL = 'initial'; +const STATE_ENQUIRY = 'enquiry'; +const STATE_PREAUTHORIZED = 'preauthorized'; +const STATE_DECLINED = 'declined'; +const STATE_ACCEPTED = 'accepted'; +const STATE_CANCELED = 'canceled'; +const STATE_DELIVERED = 'delivered'; +const STATE_REVIEWED = 'reviewed'; +const STATE_REVIEWED_BY_CUSTOMER = 'reviewed-by-customer'; +const STATE_REVIEWED_BY_PROVIDER = 'reviewed-by-provider'; + +/** + * Description of transaction process + * + * You should keep this in sync with transaction process defined in Marketplace API + * + * Note: we don't use yet any state machine library, + * but this description format is following Xstate (FSM library) + * https://xstate.js.org/docs/ + */ +const stateDescription = { + // id is defined only to support Xstate format. + // However if you have multiple transaction processes defined, + // it is best to keep them in sync with transaction process aliases. + id: 'preauth-with-nightly-booking/release-1', + + // This 'initial' state is a starting point for new transaction + initial: STATE_INITIAL, + + // States + states: { + [STATE_INITIAL]: { + on: { + [TRANSITION_ENQUIRE]: STATE_ENQUIRY, + [TRANSITION_REQUEST]: STATE_PREAUTHORIZED, + }, + }, + [STATE_ENQUIRY]: { + on: { + [TRANSITION_REQUEST_AFTER_ENQUIRY]: STATE_PREAUTHORIZED, + }, + }, + + [STATE_PREAUTHORIZED]: { + on: { + [TRANSITION_DECLINE]: STATE_DECLINED, + [TRANSITION_EXPIRE]: STATE_DECLINED, + [TRANSITION_ACCEPT]: STATE_ACCEPTED, + }, + }, + + [STATE_DECLINED]: {}, + [STATE_ACCEPTED]: { + on: { + [TRANSITION_CANCEL]: STATE_CANCELED, + [TRANSITION_COMPLETE]: STATE_DELIVERED, + }, + }, + + [STATE_CANCELED]: {}, + [STATE_DELIVERED]: { + on: { + [TRANSITION_EXPIRE_REVIEW_PERIOD]: STATE_REVIEWED, + [TRANSITION_REVIEW_1_BY_CUSTOMER]: STATE_REVIEWED_BY_CUSTOMER, + [TRANSITION_REVIEW_1_BY_PROVIDER]: STATE_REVIEWED_BY_PROVIDER, + }, + }, + + [STATE_REVIEWED_BY_CUSTOMER]: { + on: { + [TRANSITION_REVIEW_2_BY_PROVIDER]: STATE_REVIEWED, + [TRANSITION_EXPIRE_PROVIDER_REVIEW_PERIOD]: STATE_REVIEWED, + }, + }, + [STATE_REVIEWED_BY_PROVIDER]: { + on: { + [TRANSITION_REVIEW_2_BY_CUSTOMER]: STATE_REVIEWED, + [TRANSITION_EXPIRE_CUSTOMER_REVIEW_PERIOD]: STATE_REVIEWED, + }, + }, + [STATE_REVIEWED]: { type: 'final' }, + }, +}; + +// Note: currently we assume that state description doesn't contain nested states. +const statesFromStateDescription = description => description.states || {}; + +// Get all the transitions from states object in an array +const getTransitions = states => { + const stateNames = Object.keys(states); + + const transitionsReducer = (transitionArray, name) => { + const stateTransitions = states[name] && states[name].on; + const transitionKeys = stateTransitions ? Object.keys(stateTransitions) : []; + return [ + ...transitionArray, + ...transitionKeys.map(key => ({ key, value: stateTransitions[key] })), + ]; + }; + + return stateNames.reduce(transitionsReducer, []); +}; + +// This is a list of all the transitions that this app should be able to handle. +export const TRANSITIONS = getTransitions(statesFromStateDescription(stateDescription)).map( + t => t.key +); + +// This function returns a function that has given stateDesc in scope chain. +const getTransitionsToStateFn = stateDesc => state => + getTransitions(statesFromStateDescription(stateDesc)) + .filter(t => t.value === state) + .map(t => t.key); + +// Get all the transitions that lead to specified state. +const getTransitionsToState = getTransitionsToStateFn(stateDescription); + +// This is needed to fetch transactions that need response from provider. +// I.e. transactions which provider needs to accept or decline +export const transitionsToRequested = getTransitionsToState(STATE_PREAUTHORIZED); + +/** + * Helper functions to figure out if transaction is in a specific state. + * State is based on lastTransition given by transaction object and state description. + */ + +const txLastTransition = tx => ensureTransaction(tx).attributes.lastTransition; + +// DEPRECATED: use txIsDelivered instead +export const txIsCompleted = tx => txLastTransition(tx) === TRANSITION_COMPLETE; + +export const txIsEnquired = tx => + getTransitionsToState(STATE_ENQUIRY).includes(txLastTransition(tx)); + +// Note: state name used in Marketplace API docs (and here) is actually preauthorized +// However, word "requested" is used in many places so that we decided to keep it. +export const txIsRequested = tx => + getTransitionsToState(STATE_PREAUTHORIZED).includes(txLastTransition(tx)); + +export const txIsAccepted = tx => + getTransitionsToState(STATE_ACCEPTED).includes(txLastTransition(tx)); + +export const txIsDeclined = tx => + getTransitionsToState(STATE_DECLINED).includes(txLastTransition(tx)); + +export const txIsCanceled = tx => + getTransitionsToState(STATE_CANCELED).includes(txLastTransition(tx)); + +export const txIsDelivered = tx => + getTransitionsToState(STATE_DELIVERED).includes(txLastTransition(tx)); + +const firstReviewTransitions = [ + ...getTransitionsToState(STATE_REVIEWED_BY_CUSTOMER), + ...getTransitionsToState(STATE_REVIEWED_BY_PROVIDER), +]; +export const txIsInFirstReview = tx => firstReviewTransitions.includes(txLastTransition(tx)); + +export const txIsInFirstReviewBy = (tx, isCustomer) => + isCustomer + ? getTransitionsToState(STATE_REVIEWED_BY_CUSTOMER).includes(txLastTransition(tx)) + : getTransitionsToState(STATE_REVIEWED_BY_PROVIDER).includes(txLastTransition(tx)); + +export const txIsReviewed = tx => + getTransitionsToState(STATE_REVIEWED).includes(txLastTransition(tx)); + +/** + * Helper functions to figure out if transaction has passed a given state. + * This is based on transitions history given by transaction object. + */ + +const txTransitions = tx => ensureTransaction(tx).attributes.transitions || []; +const hasPassedTransition = (transitionName, tx) => + !!txTransitions(tx).find(t => t.transition === transitionName); + +const hasPassedStateFn = state => tx => + getTransitionsToState(state).filter(t => hasPassedTransition(t, tx)).length > 0; + +export const txHasBeenAccepted = hasPassedStateFn(STATE_ACCEPTED); +export const txHasBeenDelivered = hasPassedStateFn(STATE_DELIVERED); + +/** + * Other transaction related utility functions + */ + +export const transitionIsReviewed = transition => + getTransitionsToState(STATE_REVIEWED).includes(transition); + +export const transitionIsFirstReviewedBy = (transition, isCustomer) => + isCustomer + ? getTransitionsToState(STATE_REVIEWED_BY_CUSTOMER).includes(transition) + : getTransitionsToState(STATE_REVIEWED_BY_PROVIDER).includes(transition); + +export const getReview1Transition = isCustomer => + isCustomer ? TRANSITION_REVIEW_1_BY_CUSTOMER : TRANSITION_REVIEW_1_BY_PROVIDER; + +export const getReview2Transition = isCustomer => + isCustomer ? TRANSITION_REVIEW_2_BY_CUSTOMER : TRANSITION_REVIEW_2_BY_PROVIDER; diff --git a/src/util/transaction.test.js b/src/util/transaction.test.js new file mode 100644 index 00000000..a48519c0 --- /dev/null +++ b/src/util/transaction.test.js @@ -0,0 +1,96 @@ +import { createUser, createTransaction, createListing, createTxTransition } from './test-data'; + +import { + TX_TRANSITION_ACTOR_CUSTOMER, + TX_TRANSITION_ACTOR_PROVIDER, + TX_TRANSITION_ACTOR_SYSTEM, + TRANSITION_REQUEST, + TRANSITION_ACCEPT, + TRANSITION_COMPLETE, + TRANSITION_EXPIRE_REVIEW_PERIOD, + txIsAccepted, + txIsReviewed, + txHasBeenAccepted, + txHasBeenDelivered, +} from './transaction'; + +const transitionRequest = createTxTransition({ + createdAt: new Date(Date.UTC(2017, 10, 9, 8, 10)), + by: TX_TRANSITION_ACTOR_CUSTOMER, + transition: TRANSITION_REQUEST, +}); + +const transitionAccept = createTxTransition({ + createdAt: new Date(Date.UTC(2017, 10, 9, 8, 12)), + by: TX_TRANSITION_ACTOR_PROVIDER, + transition: TRANSITION_ACCEPT, +}); + +const transitionDelivered = createTxTransition({ + createdAt: new Date(Date.UTC(2017, 10, 16, 8, 12)), + by: TX_TRANSITION_ACTOR_SYSTEM, + transition: TRANSITION_COMPLETE, +}); +const transitionReviewed = createTxTransition({ + createdAt: new Date(Date.UTC(2017, 10, 16, 8, 12)), + by: TX_TRANSITION_ACTOR_SYSTEM, + transition: TRANSITION_EXPIRE_REVIEW_PERIOD, +}); + +const txRequested = createTransaction({ + lastTransition: TRANSITION_REQUEST, + customer: createUser('user1'), + provider: createUser('user2'), + listing: createListing('Listing'), + transitions: [transitionRequest], +}); + +const txAccepted = createTransaction({ + lastTransition: TRANSITION_ACCEPT, + customer: createUser('user1'), + provider: createUser('user2'), + listing: createListing('Listing'), + transitions: [transitionRequest, transitionAccept], +}); + +const txReviewed = createTransaction({ + lastTransition: TRANSITION_EXPIRE_REVIEW_PERIOD, + customer: createUser('user1'), + provider: createUser('user2'), + listing: createListing('Listing'), + transitions: [transitionRequest, transitionAccept, transitionDelivered, transitionReviewed], +}); + +describe('transaction utils', () => { + describe('tx is in correct state', () => { + it(`txIsReviewed(txReviewed) succeeds with last transaction: ${TRANSITION_EXPIRE_REVIEW_PERIOD}`, () => { + expect(txIsReviewed(txReviewed)).toEqual(true); + }); + it(`txIsAccepted(txReviewed) fails with last transaction: ${TRANSITION_EXPIRE_REVIEW_PERIOD}`, () => { + expect(txIsAccepted(txReviewed)).toEqual(false); + }); + }); + + describe('tx has passed a state X', () => { + it('txHasBeenAccepted(txRequested) fails', () => { + expect(txHasBeenAccepted(txRequested)).toEqual(false); + }); + it('txHasBeenDelivered(txRequest) fails', () => { + expect(txHasBeenDelivered(txRequested)).toEqual(false); + }); + + it('txHasBeenAccepted(txAccepted) succeeds', () => { + expect(txHasBeenAccepted(txAccepted)).toEqual(true); + }); + it('txHasBeenDelivered(txAccepted) fails', () => { + expect(txHasBeenDelivered(txAccepted)).toEqual(false); + }); + + it('txHasBeenAccepted(txReviewed) succeeds', () => { + expect(txHasBeenAccepted(txReviewed)).toEqual(true); + }); + it('txHasBeenDelivered(txReviewed) succeeds', () => { + expect(txHasBeenDelivered(txReviewed)).toEqual(true); + }); + }); +});