Skip to content

Commit

Permalink
Use fresh data for transactions (#139)
Browse files Browse the repository at this point in the history
* Add reducer with some basic actions

The reducer does define some actions, but never produces a different
state.

* Add function to initalize the data store for fresh-data

* Add function to initialize the fresh-data API client

* Add empty Payments API spec

This will be filled in as we go, but can remain empty for now since
we're not actually calling any Payments endpoints for data.

* Add dependencies to package.json

* Add function to initialize the Payments data store API

* Include the fresh-data Payments data store in web app

* Refactor TransactionsList to React Component class

This is necessary to use the component with fresh-data. Currently the
component always shows an empty table.

* Add withSelect function

This is used to wrap components before exporting them, and allows
components to access the fresh-data data store.

* Wrap TransactionsList export with `withSelect`

This will allow us to access the fresh-data data store.

* Add hard coded test data to transactions fresh-data selector

The selector really shouldn't be using hard coded data, but this is for
testing! All for the greater good, I'm sure.

* Add transactions selector to the Payments API spec

* Retrieve data to display in TransactionsList using transactions selector

By retrieving the relevant selectors we can access the data from the
fresh-data data store, and use that to populate the transactions table.

As far as I understand this (@coderkevin would be a better person to
ask) the `withSelect` method basically injects data from the selector
into the component's `this.props`, allowing us to use the data in the
component.

* Update package-lock.json

* Fix wrong parameter name in `createApiClient`

* Add reducer for `fresh-data`

* Add lodash to dependencies

* Add read operation for transactions list

This operation defines how requests are sent for `fresh-data`
transactions resources, i.e. how the transactions list is retrieved.

* Expose the transactions list 'read' operation to `fresh-data`

* Add transactions list selector

The selector can be used to retrieve transactions list for components.
This commit also adapts the code to fit the new selector better.

* Add code to determine if the table loading view should be displayed

* Fix `getTransactionsLoading` function

Incorrectly accessing the resource object caused the timestamps to be
undefined when evaluating whether the transactions request is loading or
not.

* Update fresh-data version in package.json

* Change loading status selector to not specify requirements

* Export transactions operations to improve testability

* Remove unnecessary setup code from fresh-data data store initialization

* Add packages necessary to run tests

For some reason jest can't find '@wordpress/api-fetch' unless it's
installed. Even though the test doesn't actually _use_ api-fetch, it
complains because one of the included files imports api-fetch.

* Fix transactions operations

* Add transactions operation tests

* Add transactions selector tests

* Merge changes to transactions-list.js from master

* Add `showTransactionsPlaceholder` selector

Used to indicate whether the `TableCard` in the transactions list should
show the loading placeholder view or not.

* Fix `...` (spread operator) missing error.

Babel seems to need a specific package to support the `...` operator for
now.
See: babel/babel#10179 (comment)

npm i --save-dev @babel/plugin-proposal-object-rest-spread is sufficient
as a fix.
  • Loading branch information
reykjalin committed Aug 7, 2019
1 parent 0e65042 commit ea26fd1
Show file tree
Hide file tree
Showing 18 changed files with 1,009 additions and 59 deletions.
1 change: 1 addition & 0 deletions client/index.js
Expand Up @@ -10,6 +10,7 @@ import { addFilter } from '@wordpress/hooks';
import './style.scss';
import { HelloWorld } from 'hello-world';
import TransactionsPage from 'transactions';
import 'payments-api/payments-data-store';

const DepositsPage = () => <HelloWorld>Hello from the deposits page</HelloWorld>;
const DisputesPage = () => <HelloWorld>Hello from the disputes page</HelloWorld>;
Expand Down
31 changes: 31 additions & 0 deletions client/payments-api/api-spec/payments-rest-api.js
@@ -0,0 +1,31 @@
/** @format */

/**
* Internal dependencies.
*/
import transactions from './transactions';

function createPaymentsApiSpec() {
return {
name: 'wcPaymentsApi',
mutations: {},
selectors: {
...transactions.selectors,
},
operations: {
read( resourceNames ) {
return [
...transactions.operations.read( resourceNames ),
];
},
update( resourceNames, data ) {
return [];
},
updateLocally( resourceNames, data ) {
return [];
},
},
};
}

export default createPaymentsApiSpec();
12 changes: 12 additions & 0 deletions client/payments-api/api-spec/transactions/index.js
@@ -0,0 +1,12 @@
/** @format */

/**
* Internal dependencies.
*/
import selectors from './selectors';
import operations from './operations';

export default {
selectors,
operations,
};
44 changes: 44 additions & 0 deletions client/payments-api/api-spec/transactions/operations.js
@@ -0,0 +1,44 @@
/** @format */

/**
* External dependencies.
*/
import apiFetch from '@wordpress/api-fetch';
import { includes } from 'lodash';

/**
* Internal dependencies.
*/
import { NAMESPACE } from '../../constants';

function read( resourceNames, fetch = apiFetch, dataToResources = transactionsToResources ) {
return readTransactions( resourceNames, fetch, dataToResources );
}

export function readTransactions( resourceNames, fetch, dataToResources ) {
if ( includes( resourceNames, 'transactions-list' ) ) {
const url = `${ NAMESPACE }/payments/transactions`;

return [
fetch( { path: url } )
.then( dataToResources )
.catch( error => {
return { [ resourceName ]: { error } };
} )
];
}

return [];
}

export function transactionsToResources( transactions ) {
return {
[ 'transactions-list' ]: {
data: transactions,
}
};
}

export default {
read,
};
42 changes: 42 additions & 0 deletions client/payments-api/api-spec/transactions/selectors.js
@@ -0,0 +1,42 @@
/** @format */

/**
* External dependencies.
*/
import { isNil } from 'lodash';

/**
* Internal dependencies.
*/
import { DEFAULT_REQUIREMENT } from '../../constants';

const getTransactions = ( getResource, requireResource ) => (
requirement = DEFAULT_REQUIREMENT
) => {
return requireResource( requirement, 'transactions-list' ).data || {};
}

const isWaitingForInitialLoad = ( getResource ) => () => {
const resourceName = 'transactions-list';
const transactionsResource = getResource( resourceName );

return transactionsResource.lastReceived === undefined;
}

const getTransactionsIsLoading = ( getResource ) => () => {
const resourceName = 'transactions-list';
const transactionsResource = getResource( resourceName );

return transactionsResource.lastRequested > transactionsResource.lastReceived;
}

const showTransactionsPlaceholder = ( getResource ) => () => {
return isWaitingForInitialLoad( getResource )();
}

export default {
getTransactions,
getTransactionsIsLoading,
isWaitingForInitialLoad,
showTransactionsPlaceholder,
};
12 changes: 12 additions & 0 deletions client/payments-api/constants.js
@@ -0,0 +1,12 @@

/**
* External dependencies.
*/
import { MINUTE } from '@fresh-data/framework';

export const DEFAULT_REQUIREMENT = {
timeout: 1 * MINUTE,
freshness: 30 * MINUTE,
};

export const NAMESPACE = '/wc/v3';
45 changes: 45 additions & 0 deletions client/payments-api/payments-data-store/create-api-client.js
@@ -0,0 +1,45 @@

/**
* External dependencies.
*/
import { ApiClient } from '@fresh-data/framework';

/**
* Internal dependencies.
*/
import createStore from './create-store';

function createDataHandlers( store ) {
return {
dataRequested: resourceNames => {
store.dispatch( {
type: 'FRESH_DATA_REQUESTED',
resourceNames,
time: new Date(),
} );
},
dataReceived: resources => {
store.dispatch( {
type: 'FRESH_DATA_RECEIVED',
resources,
time: new Date(),
} );
},
};
}

function createApiClient( name, apiSpec ) {
const store = createStore( name );
const dataHandlers = createDataHandlers( store );
const apiClient = new ApiClient( apiSpec );
apiClient.setDataHandlers( dataHandlers );

const storeChanged = () => {
apiClient.setState( store.getState() );
}
store.subscribe( storeChanged );

return apiClient;
}

export default createApiClient;
16 changes: 16 additions & 0 deletions client/payments-api/payments-data-store/create-store.js
@@ -0,0 +1,16 @@

/**
* External dependencies.
*/
import { createStore } from 'redux';

/**
* Internal dependencies.
*/
import reducer from './reducer';

export default name => {
const devTools = window.__REDUX_DEVTOOLS_EXTENSION__;

return createStore( reducer, devTools && devTools( { name: name, instanceId: name } ) );
}
31 changes: 31 additions & 0 deletions client/payments-api/payments-data-store/index.js
@@ -0,0 +1,31 @@

/**
* External dependencies.
*/
import { registerGenericStore } from '@wordpress/data';

/**
* Internal dependencies.
*/
import createApiClient from './create-api-client';
import paymentsApiSpec from '../api-spec/payments-rest-api';

if ( 'development' === process.env.NODE_ENV ) {
window.__FRESH_DATA_DEV_INFO__ = true;
}

function createPaymentsApiStore() {
const apiClient = createApiClient( 'wc-payments-api', paymentsApiSpec );

return {
getSelectors: () => {
return apiClient.getSelectors();
},
getActions: () => {
return apiClient.getMutations();
},
subscribe: apiClient.subscribe,
};
}

registerGenericStore( 'wc-payments-api', createPaymentsApiStore() );
52 changes: 52 additions & 0 deletions client/payments-api/payments-data-store/reducer.js
@@ -0,0 +1,52 @@

const defaultState = {
resources: {},
};

export default function reducer( state = defaultState, action ) {
switch ( action.type ) {
case 'FRESH_DATA_REQUESTED':
return reduceRequested( state, action );
case 'FRESH_DATA_RECEIVED':
return reduceReceived( state, action );
default:
return state;
}
}

export function reduceRequested( state, action ) {
const newResources = action.resourceNames.reduce( ( resources, name ) => {
resources[ name ] = { lastRequested: action.time };
return resources;
}, {} );
return reduceResources( state, newResources );
}

export function reduceReceived( state, action ) {
const newResources = Object.keys( action.resources ).reduce( ( resources, name ) => {
const resource = { ...action.resources[ name ] };
if ( resource.data ) {
resource.lastReceived = action.time;
}
if ( resource.error ) {
resource.lastError = action.time;
}
resources[ name ] = resource;
return resources;
}, {} );
return reduceResources( state, newResources );
}

export function reduceResources( state, newResources ) {
const updatedResources = Object.keys( newResources ).reduce(
( resources, resourceName ) => {
const resource = resources[ resourceName ];
const newResource = newResources[ resourceName ];
resources[ resourceName ] = { ...resource, ...newResource };
return resources;
},
{ ...state.resources }
);

return { ...state, resources: updatedResources };
}
3 changes: 3 additions & 0 deletions client/payments-api/test/api-spec/transactions/index.js
@@ -0,0 +1,3 @@
/** @format */
import './operations';
import './selectors';
69 changes: 69 additions & 0 deletions client/payments-api/test/api-spec/transactions/operations.js
@@ -0,0 +1,69 @@
/** @format */

/**
* Internal dependencies.
*/
import { readTransactions, transactionsToResources } from '../../../api-spec/transactions/operations';
import { NAMESPACE } from '../../../constants';

describe( 'Transactions operations', () => {
describe( 'readTransactions()', () => {
const expectedUrl = `${ NAMESPACE }/payments/transactions`;

it( 'Returns a list with one promise when correct resource names are supplied', () => {
const mockData = [ {}, {}, {} ];
const expectedResolvedPromise = {
[ "transactions-list" ]: {
data: mockData,
},
};

const mockToResources = jest.fn();
mockToResources.mockReturnValue( expectedResolvedPromise );

const mockPromise = new Promise( () => mockData, () => {} );
const expectedPromises = [ mockPromise ];

const mockFetch = jest.fn();
mockFetch.mockReturnValue( mockPromise );

// Perform read operation.
const promises = readTransactions( [ 'transactions-list' ], mockFetch, mockToResources );

expect( mockFetch ).toHaveBeenCalledTimes( 1 );
expect( mockFetch ).toHaveBeenCalledWith( { path: expectedUrl } );
expect( promises ).toStrictEqual( expectedPromises );
promises[0].then( result => {
expect( mockToResources ).toHaveBeenCalledTimes( 1 );
expect( mockToResources ).toHaveBeenCalledWith( mockData );
expect( result ).toBe( expectedResolvedPromise );
} );
} );

it( 'Returns an empty list when wrong resource names are supplied', () => {
const expected = [];

const mockFetch = jest.fn();

// Perform read operation.
const promises = readTransactions( [ 'wrong', 'resource', 'names' ] );

expect( mockFetch ).not.toHaveBeenCalled();
expect( promises ).toStrictEqual( expected );
} );
} );

describe( 'transactionsToResources()', () => {
it( 'Transactions list is correctly converted to resources', () => {
const mockData = [ {}, {}, {} ];
const expected = {
[ 'transactions-list' ]: {
data: mockData,
},
};

const resources = transactionsToResources( mockData );
expect( resources ).toStrictEqual( expected );
} );
} );
} );

0 comments on commit ea26fd1

Please sign in to comment.