Add state graph description and refactor transaction.js functions to use it.

This commit is contained in:
Vesa Luusua 2019-01-28 18:43:49 +02:00
parent 6d6ebbffc1
commit caad06353e
8 changed files with 337 additions and 95 deletions

View file

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

View file

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

View file

@ -285,6 +285,14 @@
}
}
.breakdownContainer {
display: none;
@media (--viewportLarge) {
display: block;
}
}
/* FeedSection subcomponent */
.feedHeading {
color: var(--matterColorAnti);

View file

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

View file

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

View file

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

View file

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

View file

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