I recently posted about some news in v18.1 of our Report and Dashboard Server and it made me curious to see how an interface could show a report without any ASP.NET components. We have several demos available that show the use of JavaScript applications as report viewing or editing front-ends (such as this one for React), but they assume that there’s a .NET based back-end, usually using ASP.NET MVC. The associated instructions also use bower
, which isn’t something I would do today when creating a React app.
I set myself the goal to create a React app using create-react-app (duh!) and implement access to Report and Dashboard Server to show a report in the app, using the HTML5 Document Viewer. The basic steps of a solution are outlined on this documentation page, but again assume ASP.NET MVC.
The post below describes the steps I took in enough detail for you to follow along if you’re interested. Please let me know if you find anything missing or inadequately explained!
Preparation
After setting up Report and Dashboard Server in a virtual machine, I configured the system with a Data Model and I created a report. I also set up email sending for the server and created a user account, which is necessary so I can authenticate the remote viewer using that account. Following the instructions I linked in the previous paragraph, I activated CORS.
Now I set out to create a first piece of code to run on Node.js, which contacts the server, retrieves a security token and then a list of available reports. Here’s what I ended up with:
const base = 'http://192.168.1.234:83'; const data = r => r.data; axios .post( base + '/oauth/token', qs.stringify({ username: 'sturm', password: 'secret', grant_type: 'password' }), { headers: { 'content-type': 'application/x-www-form-urlencoded' } } ) .then(data) .then(d => d.access_token) .then(token => { console.log('Token: ', token); return axios .get(base + '/api/documents', { headers: { Authorization: 'Bearer ' + token } }) .then(data) .then(d => { console.dir(d); }); }) .catch(err => { console.error(err); });
From the top, here are the steps of the process. First, I have an address and a port for the server, which depend on the server configuration. Port 83 is the default for a new installation and I went with that. You can see that I’m using HTTP as the protocol — more about that below.
Using axios (because I like Promises), I make a POST
request to the server, using the path /oauth/token
. The information I send is URL-encoded and I achieve this using the qs library. There are other solutions for this, but qs
is easy to use and works on Node as well as in the browser. As a result of this call, I receive a Bearer Token from the server, which I need to use in further calls to prove authorization.
Note that the token expires sooner or later — mine were valid for twenty minutes each — so you should make sure to retrieve a new one close enough to the actual call you’re going to make!
To retrieve the list of available reports, I make a GET
request to /api/documents
and I pass the token as part of the Authorization
header. This is of course a standard OAuth mechanism. The result is a list of available reports:
[ { id: 1, categoryId: 1, modifiedById: 0, name: 'Venues', description: '', modifiedBy: 'DESKTOP-TMVPFDM\\sturm', modifiedWhen: '2018-05-23T10:08:06.867', documentType: 'Report', allowModify: false, allowDelete: false, optimisticLock: 0 } ],
That was simple enough! There’s just one important thing to sort out…
HTTP - or what?
By default, the OAuth token can not be retrieved from the server using HTTP, it requires HTTPS. There is a good reason: if a third party should get hold of the token, they would be able to access the server using my credentials!
Since I’m running Report and Dashboard Server in a local virtual machine, I can’t easily get hold of a “real” (i.e. not a self-signed) SSL certificate. IIS will gladly provide a self-signed certificate for development purposes, but this is not accepted by a client without some extra steps. On Node, I can tell the client to ignore validation errors by using an HTTPS Agent:
const agent = new https.Agent({ host: '192.168.1.234', port: 443, path: '/', rejectUnauthorized: false });
However, this agent mechanism is not available in the browser, since browsers are not allowed to ignore certificate validation issues without direct user confirmation. This means that a self-signed certificate is useless for AJAX calls, even during development.
For my development and test scenario, I chose to deactivate the HTTPS requirement for OAuth. However, before I tell you how that works, you must understand that this is NEVER an acceptable solution for a real-world server outside a dev environment! I recommend you get a signed certificate for your server, for example using Windows ACME Simple in conjunction with Let’s Encrypt.
Now, here’s how to allow Report and Dashboard Server to use HTTP for OAuth, purely for development purposes and entirely at your own risk. Find the table GlobalSettings
in the server database (by default called DevExpressReportServer
). In that table, find the record with the Key
value OAuthServerAllowInsecureHttp
and set the associated Value
to True
. Restart the service using IIS Manager and you’ll be able to run code like mine above.
The React app
With all the preparation and research out of the way, I created a new React app:
create-react-app report-server-react
Since I never see the point in using yarn
, I switched things to npm
and made sure everything was installed correctly:
cd report-server-react rm -rf yarn.lock node_modules npm install
Now I installed required packages, starting with the ones to support the communication logic already shown above:
npm install --save axios qs
I also need the packages for the DevExpress functionality, plus globalize
and cldr
to support internationalization:
npm install --save cldr globalize devextreme devexpress-reporting
Finally, I need a special loader for webpack:
npm install --save-dev html-loader
Now for some code
I edited the file src/App.js
and removed the default component implementation and the logo
import. I added some import lines to pull in all the required library functionality:
import axios from 'axios'; import qs from 'qs'; import ko from 'knockout'; import 'devextreme/dist/css/dx.common.css'; import 'devextreme/dist/css/dx.light.css'; import 'devexpress-reporting/css/web-document-viewer-light.min.css'; const viewerHtml = require('devexpress-reporting/dx-web-document-viewer').Html;
I added a new App
class:
class App extends Component { constructor(props) { super(props); this.state = { reportName: 'report/1' }; } render() { return <ReportViewer reportUrl={this.state.reportName} />; } }
Like in some of our existing examples, the value passed to the reportUrl
attribute is retrieved from state
. In the demo, I’m setting the value statically to report/<id>
(that’s the id
from the report list). Of course it is easy to imagine that the value would be retrieved from some other source in a real application.
Finally, here is the ReportViewer
component. Again, this is based on our other examples. It is possible to separate the token retrieval logic, but I left it in place here to keep the structure simple.
class ReportViewer extends React.Component { constructor(props) { super(props); this.reportUrl = ko.observable(props.reportUrl); } render() { return (<div><div ref="innerScript" /><div className="fullscreen" data-bind="dxReportViewer: $data" /></div> ); } componentWillReceiveProps(newProps) { this.reportUrl(newProps.reportUrl); } componentDidMount() { this.refs.innerScript.innerHTML = viewerHtml; const baseUrl = 'http://192.168.1.234:83'; axios .post( baseUrl + '/oauth/token', qs.stringify({ username: 'sturm', password: 'secret', grant_type: 'password' }), { headers: { 'content-type': 'application/x-www-form-urlencoded' } } ) .then(r => r.data) .then(d => d.access_token) .then(token => { ko.applyBindings( { reportUrl: this.reportUrl, remoteSettings: { serverUri: baseUrl, authToken: token } }, this.refs.viewer ); }); } componentWillUnmount() { ko.cleanNode(this.refs.viewer); } }
Once more, from the top, here are the important details of the implementation. First, in the constructor, there is a call to Knockout. This library is used by the document viewer internally, so I need to interface with it from my code. I create an observable value, initializing it with the reportUrl
from my own props
.
In the render
method, two container divs
are created. The second one has a data-bind
attribute, which is later recognized by Knockout. Since the attribute uses the dxReportViewer
binding type, Knockout will integrate the viewer in place of the container component.
In componentWillReceiveProps
, I update the internal observable value if and when the same value in the props
changes. This in turn updates the viewer since Knockout reacts to the change to the observable value.
The method componentDidMount
is called when the React component is ready, and you’ll recognize most of the code in there. The new part is the call to ko.applyBindings
: at this point, Knockout receives a data model, which in this case is a structure with document viewer parameters. These are used by the data binding described above, since there is the $data
variable in the attribute.
The only remaining element is a bit of clean-up logic in componentWillUnmount
.
One final code addition was required: I edited the file src/App.css
and added this block at the end, so that the viewer appears in a full-screen setup:
.fullscreen { position: absolute; top: 0px; left: 0; right: 0; bottom: 20px; }
And then… nothing worked
With all the code in place, it should have been possible to run the application at this point. Since I used create-react-app
, everything was set up for a simple command:
npm start
However, there were errors reported when I did this. The reason was that the globalize
and cldr
libraries are not compatible with webpack
by default — and webpack
is used by the create-react-app
infrastructure, even if it’s almost invisible.
To solve this problem (and one more, below) it is necessary to modify the hidden webpack
configuration that has been generated by create-react-app
. Unfortunately this is not supported in the default setup. In fact, the makers of create-react-app
advise that whenever you feel the need to break out of the standard system, you should let them know why, so they can fix the original issue rather than support customization.
Note: The project react-app-rewired aims to provide customizability of projects created using create-react-app
, at a tradeoff. For real-world scenarios I recommend considering this seriously, for the demo however I didn’t want to complicate matters further by bringing in additional tools.
There is a mechanism in place for the purpose of breaking out, and it’s called eject. Once you eject your project, you are free to modify details like the webpack
config files, but you will not be able to use your project with new versions of create-react-app
and react-scripts
in the future. There are lots of discussions to be found online on the pros and cons of ejecting. For the purposes of this demo, I chose to do it:
npm run eject
Note: If you’re following along with my description, please click the survey link displayed at the end of the eject process and let the project maintainers know that you ejected due to lack of support for CLDR, Globalize and custom loaders.
Adjusting webpack config files
I edited the file config/webpack.config.dev.js
and found the block resolve.alias
. It contained an item called react-native
by default, and I added these lines to solve the integration problems that came up for CLDR and Globalize.
cldr$: 'cldrjs', cldr: 'cldrjs/dist/cldr', globalize: 'globalize/dist/globalize'
I made one more change by integrating the html-loader
you saw already when I added the package. This was necessary because the devexpress-reporting
package contains two files with HTML snippets, and these need to be supported by the loader. I found the block modules.rules
and navigated towards the end of it. There is a segment there for the configuration of the file-loader
, with a comment right behind it indicating that any custom loaders need to be added ahead of that block. I inserted my setup code in the correct position:
{ test: [/\.html$/], include: [ path.resolve(paths.appNodeModules, 'devexpress-reporting/html') ], loader: require.resolve('html-loader') },
This configuration means that for any HTML files located in the devexpress-reporting
package folder, the html-loader
is used.
If you intend you build a production version of your project (npm run build
), you will need to make the same changes to the file config/webpack.config.prod.js
.
That’s it!
If you have been following along, all that remains at this point is to run the application:
npm start
This opens a page in a browser automatically, at http://localhost:3000, showing the running application. If all goes well, it will contact the Report and Dashboard Server, retrieve the report and show it in the HTML5 Document Viewer!
I’m really interested in your thoughts. Will you use this type of integration in your own applications? Please feel free to get back to us if you need more help with your own integration scenarios!
Finally, here is a repository with my own demo app. If you change the URL strings to point to your own server, this should work fine!