diff --git a/.travis.yml b/.travis.yml index 6bfbd20..66c34c4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -20,7 +20,7 @@ script: - npm run lint - npm run test-ci - npm run build -# - npm run coveralls + - npm run coveralls - 'if [ "$TRAVIS_PULL_REQUEST" = "false" ]; then sonar-scanner; fi' deploy: diff --git a/CHANGELOG.md b/CHANGELOG.md index dbdd070..aa56afa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,9 +11,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Removed ### BREAKING CHANGES +## [1.1.0] - 2019-08-21 +### Added +- Add server side data behavior tests and documentation. + ## [1.1.0-beta.2] - 2019-08-19 ### Added -- Add helper for registering sources that should be loaded during server side data rendering. +- Add method for registering sources that should be loaded during server side data rendering. ### Fixed - Fix server side data connect. Load data in client when no server data is available diff --git a/README.md b/README.md index 5bf6272..2c9cc3e 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,71 @@ export const ConnectedModule = connect( > This parser function should not be used as a replacement to Mercury Selectors. As a good practice, the preference is to use Selectors if possible instead of this function. +## Prefetching data on server side rendering + +Methods for prefetching data on server side rendering are available too. When data is prefetched in server side, the connect function will pass the `value` property calculated on server side to the components directly. It will not modify the `loading` property until the first load on client side is finished (At first client-side load, the resource will not be considered as `loading` to maintain the server-side value in the component until it finish loading). + +### Server side data methods and components + +* `addServerSideData(source)` + * Arguments + * source - ` or of ` Mercury source or sources that should be read when `readServerSideData` method is executed. Can be Mercury origins or selectors of any type, queried or not. +* `readServerSideData([source])` + * Arguments + * source - ` or of ` Mercury source or sources that should be read when `readServerSideData` method is executed. Can be Mercury origins or selectors of any type, queried or not. + * Returns + * `` This method is asynchronous, and returns an object containing all server side data ready to be set on the `` context component. +* `` Component that sets the result of the `readServerSideData` method in a context to make it available from all mercury connected children components. + * Props + * data - `` Object containing the result of the `readServerSideData` method. + * clientSide - `` If false, the connect method will not dispatch automatically the read method of the sources marked as "server-side", so, for example, api requests will not be repeated on client side, and data retrieved in server side will be always passed to connected components. + +### Example of server side prefecth implementation in a Next project: + +In the next example, the data of the "myDataSource" mercury source will be fetched on server side and request will not be repeated on client side. The component will be rendered directly with server side data, and no loading state will be set: + +```jsx +// src/home.js +import { addServerSideData, connect } from "@xbyorange/react-mercury"; +import { myDataSource } from "src/data"; + +addServerSideData(myDataSource); // source is marked to be read when readServerSideData method is executed. + +export const HomeComponent = ({data}) => { + if(data.loading) { + return
Loading...
+ } + return
{data.value}
+}; + +export const mapDataSourceToProps = () => ({ + data: myDataSource.read +}); + +export const Home = connect(mapDataSourceToProps)(HomeComponent) + +``` + +```jsx +// pages/index.js +import { readServerSideData, ServerSideData } from "@xbyorange/react-mercury"; +import { Home } from "src/home"; + +const Page = ({ serverSideData }) => ( + + + +); + +Page.getInitialProps = async () => { + return { + serverSideData: await readServerSideData() + } +} + +export default Page; +``` + ## Demo To run a real implementation example in a React app, you can clone the project and execute the provided demo: diff --git a/jest.config.js b/jest.config.js index f3086b9..386ef02 100644 --- a/jest.config.js +++ b/jest.config.js @@ -17,10 +17,10 @@ module.exports = { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 75, - functions: 75, - lines: 80, - statements: 80 + branches: 100, + functions: 100, + lines: 100, + statements: 100 } }, diff --git a/package-lock.json b/package-lock.json index 4d8283a..908cc6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1368,9 +1368,9 @@ "dev": true }, "@xbyorange/mercury": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@xbyorange/mercury/-/mercury-1.0.0.tgz", - "integrity": "sha512-wBH4BXZI3yXpG+1afHiDYGoMTxQ2ZBfcWyDTsyQ2POGkRNZz3cMQJtM0DyuF3Fr3BXUK1CTSoKqT6tHMla5bzA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@xbyorange/mercury/-/mercury-1.1.0.tgz", + "integrity": "sha512-bh/T75kxdq1tDFCvxrsH+lkW6fxFxm8Qlw/KkUwD1VjgnWPCNouawKm4TmuUrapnHa+DAictHr2NniclIwGnKQ==", "dev": true, "requires": { "is-promise": "^2.1.0", @@ -1378,58 +1378,16 @@ } }, "@xbyorange/mercury-api": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@xbyorange/mercury-api/-/mercury-api-1.0.1.tgz", - "integrity": "sha512-IRdrMv80bIsLz/HPlwcX4lQ3sHu3a3VLoNCOFRJZyDgiV//QGLajWsrtfwk6y/3W/l7P75Ia7pWJs7T84U6zrQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xbyorange/mercury-api/-/mercury-api-1.2.0.tgz", + "integrity": "sha512-ZGE86jFHpLR1z9UyO8UcbMc3ZnX/wzG4RMdvQQz9ey2lUEYp96ckNtQokGEvgS2go35pw/FI38k1te6+ir56IA==", "dev": true, "requires": { - "@xbyorange/mercury": "1.0.0", + "@xbyorange/mercury": "1.1.0", "axios": "^0.19.0", - "axios-retry": "^3.1.1", + "axios-retry": "^3.1.2", "lodash": "4.17.11", "path-to-regexp": "^3.0.0" - }, - "dependencies": { - "axios": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.19.0.tgz", - "integrity": "sha512-1uvKqKQta3KBxIz14F2v06AEHZ/dIoeKfbTRkK1E5oqjDnuEerLmYTgJB5AiQZHJcljpg1TuRzdjDR06qNk0DQ==", - "dev": true, - "requires": { - "follow-redirects": "1.5.10", - "is-buffer": "^2.0.2" - } - }, - "debug": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", - "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", - "dev": true, - "requires": { - "ms": "2.0.0" - } - }, - "follow-redirects": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz", - "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==", - "dev": true, - "requires": { - "debug": "=3.1.0" - } - }, - "is-buffer": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.3.tgz", - "integrity": "sha512-U15Q7MXTuZlrbymiz95PJpZxu8IlipAp4dtS3wOdgPXx3mqBnslrWU14kxfHB+Py/+2PVKSr37dMAgM2A4uArw==", - "dev": true - }, - "ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", - "dev": true - } } }, "abab": { diff --git a/package.json b/package.json index 098cca1..5f17fe3 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,8 @@ "coveralls": "cat ./coverage/lcov.info | coveralls" }, "peerDependencies": { - "react": "^16.7.0" + "react": "^16.7.0", + "@xbyorange/mercury": "^1.1.0" }, "dependencies": { "hoist-non-react-statics": "^3.2.1", @@ -42,7 +43,8 @@ "@babel/core": "^7.4.5", "@babel/preset-env": "^7.4.5", "@babel/preset-react": "^7.0.0", - "@xbyorange/mercury-api": "1.0.1", + "@xbyorange/mercury-api": "1.2.0", + "@xbyorange/mercury": "^1.1.0", "axios": "^0.19.0", "axios-retry": "^3.1.2", "babel-core": "^7.0.0-bridge.0", diff --git a/src/readServerSideData.js b/src/readServerSideData.js index a064d47..e495f27 100644 --- a/src/readServerSideData.js +++ b/src/readServerSideData.js @@ -22,15 +22,15 @@ const resultsToObject = results => { }; export const addServerSideData = sources => { - const sourcesToAdd = isArray(sources) ? sources : [sources]; - sourcesToAdd.forEach(source => { - serverSideData.add(source); - }); + if (sources) { + const sourcesToAdd = isArray(sources) ? sources : [sources]; + sourcesToAdd.forEach(source => { + serverSideData.add(source); + }); + } }; -export const readServerSideData = (...args) => { - args.forEach(source => { - addServerSideData(source); - }); +export const readServerSideData = sources => { + addServerSideData(sources); return Promise.all(Array.from(serverSideData).map(getSourceData)).then(resultsToObject); }; diff --git a/test/connect.spec.js b/test/connect.spec.js index 86fc973..70efb41 100644 --- a/test/connect.spec.js +++ b/test/connect.spec.js @@ -3,8 +3,10 @@ import Enzyme, { shallow, mount } from "enzyme"; import Adapter from "enzyme-adapter-react-16"; import PropTypes from "prop-types"; import AxiosMock from "./Axios.mock.js"; +import { Selector } from "@xbyorange/mercury"; import { Api } from "@xbyorange/mercury-api"; -import { connect } from "../src/index"; +import sinon from "sinon"; +import { connect, readServerSideData, addServerSideData, ServerSideData } from "../src/index"; import jsdom from "jsdom"; const { JSDOM } = jsdom; @@ -81,19 +83,35 @@ describe("react connect plugin", () => { author: "Hemingway" } ]; + let sandbox; let books; + let booksServerSide; let axios; beforeAll(() => { + sandbox = sinon.createSandbox(); axios = new AxiosMock(); books = new Api("/books", { defaultValue: [] }); + booksServerSide = new Api("/books", { + defaultValue: [] + }); + books.client = axios._stub; + booksServerSide.client = axios._stub; + sandbox.spy(booksServerSide, "read"); + + addServerSideData(booksServerSide); }); afterAll(() => { axios.restore(); + sandbox.restore(); + }); + + afterEach(() => { + sandbox.reset(); }); beforeEach(() => { @@ -134,6 +152,178 @@ describe("react connect plugin", () => { wrapper.unmount(); }); + it("if source is not server side, it should pass source properties to component and update value when finish loading even when clientSide is disabled in context", async () => { + expect.assertions(2); + const mapDataSourceToProps = () => ({ + books: books.read + }); + const ConnectedBooks = connect(mapDataSourceToProps)(BooksList); + + const wrapper = mount( + + + + ); + books.clean(); + wrapper.update(); + expect(wrapper.find(".loading").length).toEqual(1); + await books.read(); + wrapper.update(); + expect(wrapper.find("li.book").length).toEqual(2); + wrapper.unmount(); + }); + + it("should render serverSide properties, update value when finish loading, and do not update loading property", async () => { + expect.assertions(6); + const mapDataSourceToProps = () => ({ + books: booksServerSide.read + }); + const ConnectedBooks = connect(mapDataSourceToProps)(BooksList); + + const serverSideData = await readServerSideData(); + + const wrapper = mount( + + + + ); + + expect(wrapper.find(".loading").length).toEqual(0); + expect(wrapper.find("li.book").length).toEqual(2); + + booksServerSide.read(); + wrapper.update(); + expect(wrapper.find(".loading").length).toEqual(0); + expect(wrapper.find("li.book").length).toEqual(2); + + await booksServerSide.read(); + wrapper.update(); + expect(wrapper.find(".loading").length).toEqual(0); + expect(wrapper.find("li.book").length).toEqual(2); + wrapper.unmount(); + }); + + it("should render serverSide properties, update value when finish loading, and do not update loading property when passing a selector", async () => { + expect.assertions(6); + const booksServerSideSelector = new Selector(booksServerSide, result => result, []); + addServerSideData(booksServerSideSelector); + const mapDataSourceToProps = () => ({ + books: booksServerSideSelector.read + }); + const ConnectedBooks = connect(mapDataSourceToProps)(BooksList); + + const serverSideData = await readServerSideData(); + + const wrapper = mount( + + + + ); + + expect(wrapper.find(".loading").length).toEqual(0); + expect(wrapper.find("li.book").length).toEqual(2); + + booksServerSideSelector.read(); + wrapper.update(); + expect(wrapper.find(".loading").length).toEqual(0); + expect(wrapper.find("li.book").length).toEqual(2); + + await booksServerSideSelector.read(); + wrapper.update(); + expect(wrapper.find(".loading").length).toEqual(0); + expect(wrapper.find("li.book").length).toEqual(2); + wrapper.unmount(); + }); + + it("serverSideData behavior should work when adding many sources to readServerSideData", async () => { + expect.assertions(6); + const booksServerSideSelector = new Selector(booksServerSide, result => result, []); + addServerSideData([booksServerSideSelector, booksServerSide]); + const mapDataSourceToProps = () => ({ + books: booksServerSideSelector.read + }); + const ConnectedBooks = connect(mapDataSourceToProps)(BooksList); + + const serverSideData = await readServerSideData(booksServerSideSelector); + + const wrapper = mount( + + + + ); + + expect(wrapper.find(".loading").length).toEqual(0); + expect(wrapper.find("li.book").length).toEqual(2); + + booksServerSideSelector.read(); + wrapper.update(); + expect(wrapper.find(".loading").length).toEqual(0); + expect(wrapper.find("li.book").length).toEqual(2); + + await booksServerSideSelector.read(); + wrapper.update(); + expect(wrapper.find(".loading").length).toEqual(0); + expect(wrapper.find("li.book").length).toEqual(2); + wrapper.unmount(); + }); + + it("serverSideData behavior should work with queried selectors and getters", async () => { + expect.assertions(6); + const booksServerSideSelector = new Selector(booksServerSide, result => result, []); + const queriedBooksServerSideSelector = booksServerSideSelector.query("foo"); + addServerSideData(queriedBooksServerSideSelector); + const mapDataSourceToProps = () => ({ + booksValue: queriedBooksServerSideSelector.read.getters.value, + booksLoading: queriedBooksServerSideSelector.read.getters.loading, + booksError: queriedBooksServerSideSelector.read.getters.error + }); + const ConnectedBooks = connect(mapDataSourceToProps)(BooksList); + + const serverSideData = await readServerSideData(); + + const wrapper = mount( + + + + ); + + expect(wrapper.find(".loading").length).toEqual(0); + expect(wrapper.find("li.book").length).toEqual(2); + + queriedBooksServerSideSelector.read(); + wrapper.update(); + expect(wrapper.find(".loading").length).toEqual(0); + expect(wrapper.find("li.book").length).toEqual(2); + + await queriedBooksServerSideSelector.read(); + wrapper.update(); + expect(wrapper.find(".loading").length).toEqual(0); + expect(wrapper.find("li.book").length).toEqual(2); + wrapper.unmount(); + }); + + it("should render serverSide properties and do not dispath read if clientSide is disabled", async () => { + expect.assertions(3); + const mapDataSourceToProps = () => ({ + books: booksServerSide.read + }); + const ConnectedBooks = connect(mapDataSourceToProps)(BooksList); + + const serverSideData = await readServerSideData(); + + const wrapper = mount( + + + + ); + + expect(wrapper.find(".loading").length).toEqual(0); + expect(wrapper.find("li.book").length).toEqual(2); + expect(booksServerSide.read.callCount).toEqual(1); + + wrapper.unmount(); + }); + it("should be able to pass string properties to component", async () => { expect.assertions(2); const mapDataSourceToProps = () => ({