Components can be an entire page, or a portion of a page. Component files go in app/src/js/components
. If the component has many child components (a page with charts, tables, etc) then consider making a separate directory. In the case of the collections component:
app/src/js/components/collections/index.js
is the parent or main page.- A search component could live in
app/src/js/components/collections/search.js
. - A bar chart component that is shared with other pages could live in
app/src/js/components/charts/bar.js
.
If a component is very simple, it may not need a unique directory, and can be simply app/src/js/components/404.js
, for example.
The router tells the app what the component's URL should be. In our app, a route looks like a line of HTML, so we'll call them elements. A route element requires at minimum path
and component
properties. Path defines the url path, and component is the name of the component.
When one route is nested within another route, the urls stack. In the following example, the list component's path is /collections/list
.
<Route path='/collections' component={Collections}>
<Route path='/list' component={List} />
</Route>
The routes are all defined in app/src/js/main.js
. Make sure to import
the necessary component at the top of the file.
import Collections from './components/collections'
import List from './components/collections/list'
More on imports: if the name of the file is index.js
, you don't need to spell it out, and can instead specify the name of the parent directory. You also don't need to include the file extension (assuming it's .js
).
For more on Router, including how to pass variables in the url, see the docs on github.
Instead of using <a href="path/to/component">
tags, we use <Link to="path/to/component" />
. This gives us a few convenience features. Just remember to import the Link module:
import { Link } from 'react-router-dom';
To write an API query, we need to write actions and reducers.
Actions let the interface initiate requests. A good example: a submit button calls an action to initiate a GET. Once the API returns that data, we call another action to place it in the store, along with an accompanying identifier.
Finally, we write a reducer to identify this action and optionally manipulate the data before assigning it a namespaced location.
A high-level example:
- User navigates to the
collections
page, which starts a request to list all active collections. - The API responds, and we assign the data to
store.api.collections
as an array with format[{ id: 1 }, { id: 2 }, { id: 3 }, ...]
. - We also assign each individual collection to
store.api.collectionDetail
as an object with format{ 1: {}, 2: {}, 3: {} }
so it can be easily accessed by single collection pages. - The active collections page displays a table by accessing
this.props.api.collections
. The user clicks on a single collection to go tocollections/1
. - The single collection component decides whether the collection
{ id: 1 }
exists inthis.props.api.collectionDetail
; if it does, it renders using that data. Otherwise, it initiates a new GET request.
We might want to write an action to query a single granule by id. To do this, we create a function in src/js/actions/index.js
.
export const getGranule = function (granuleId) {
return function (dispatch) {
// do ajax query
request.get(granuleId, function (err, resp) {
if (err) { console.log(err); }
dispatch(setGranule(granuleId, resp[0]));
})
};
}
Note, dispatch
allows us to write async actions.
We'll need another action to send this data to the store. Note, we probably don't need to export this action. In the same file:
function setGranule (id, granuleData) {
return { type: SET_GRANULE, id: id, data: granuleData };
}
This sends the granule data to the store. We need to specify the primary key so we can identify this action in a reducer function, and place it appropriately. In actions.js
:
export const SET_GRANULE = 'SET_GRANULE';
Now in reducers/api.js
we import the primary key and export a reducer
function, which receives the current state, and the reducer in question. We
use a primary key, because every action is sent to every reducer. The reducer
doesn't manipulate the current state, but rather returns a new state object
that includes the new data.
Since it is critical to avoid directly mutating the current state in a reducer
function, we use the createReducer
function from the
Redux Toolkit. This not only allows us to
avoid the more verbose switch
statement syntax that is normally used without
the use of a convenience function such as createReducer
, but also guarantees
that we never mutate the current state by providing a proxy state object
instead.
We can then conveniently mutate the proxy state object as if we were mutating
the actual state object, and the underlying functionality takes care of
producing a new state object for us, with the fewest possible changes. The
general pattern for each reducer within the app/src/js/reducers
directory is
as follows:
import { createReducer } from '@reduxjs/toolkit';
import {
ACTION_TYPE_1,
ACTION_TYPE_2,
...
ACTION_TYPE_N
} from '../actions/types';
export const initialState = {
// Some initial state object appropriate for the reducer
...
};
export default createReducer(initialState, {
[ACTION_TYPE_1]: (state, action) => {
state.path.to.prop1 = action.newValue1;
state.path.to.prop2 = action.newValue2;
},
[ACTION_TYPE_2]: (state, action) => {
...
},
...
[ACTION_TYPE_N]: (state, action) => {
...
}
});
Again, note that the state
parameter for each of the case reducers above is
a proxy object, not the actual state object, but it can be mutated just as if
it were. Further, you will likely never return a completely new state object,
because that is the purpose of the proxy object instead. The library manages
the creation of a new object on your behalf. Of course, if it is absolutely
necessary for some reason to return a new object, you may, but do not mutate
the proxy and also return a new object. For more details, see
createReducer.
To reduce some common boilerplate code in the case reducers, there are a few
convenience reducer creators in app/src/js/reducers/utils/reducer-creators.js
.
See the documentation in that file for more information.
Finally, this allows us to access the data from a component, where component
state is passed as a prop
:
// import the action so we can call it
import { getGranule } from '../actions';
const Granule = React.createClass({
componentWillMount: function () {
// params are passed as props to each component,
// and id is the namespace for the route in `src/js/main.js`.
const granuleId = this.props.params.id;
getGranule(granuleId);
},
render: function () {
const granuleId = this.props.params.id;
const granule = this.props.api.granuleDetail[granuleId]; // should use object-path#get for this
if (!granule) {
return <div></div>; // return empty since we have no data yet
}
return <div className='granule'>{granule.granuleId}</div>;
}
});
We follow a Bem naming format.
What to do when you need to make changes to Cumulus core in order to implement a dashboard need. For example, if you needed to update the Fake Authentication code on the @cumulus/api in order to test something on the dashboard. These instructions can be extrapolated for other core packages.
The basic steps to follow are:
-
Link your local @cumulus/api code so that it's visible to the dashboard.
This is a two step process, where you use
npm link
to prepare the @cumulus/api by runningnpm link
in thecumulus/packages/api
directory and then use it in the dashboard project by runningnpm link @cumulus/api
in the dashboard root directory.When you have done this, you need to make sure that you only use the LocalStack portion of the docker-compose containers, e.g. run
npm run start-localstack
andnpm run serve-api
in order to run the linked version of the @cumulus/api. Do all of your development locally including running unit and integration tests. When you are satisfied with the changes to the Cumulus core code (and when that is merged) you can do an alpha release of the code by building an alpha release of the packagenpm version prerelease --preid=alpha
and then publishing to npm with a tag ofnext
.npm publish --tag next
. This will upload the contents of yourcumulus/packages/api
to npm with a version that looks like1.18.1-alpha.0
and a tag ofnext
. -
Update the dashboard to use the alpha package for CI.
Update package.json to use the alpha version of the @cumulus/api by installing it
npm install @cumulus/[email protected] --save-dev
This will install the package from npm as well as allow Earthdata Bamboo's CI to run all of the tests. -
Clean up when core package is released.
When Cumulus core releases a new version, install it to the dashboard, make sure that tests still pass and then deprecate the alpha version that was published.
npm deprecate @cumulus/[email protected]
, this will remove it from the current version history on npm. andnpm dist-tag rm @cumulus/[email protected] next
to remove the tag and prevent the alpha package from showing up under current tags.