How To: Write a Feature for Steemit

in #steemit5 years ago

feature tutorial.jpg

If you're a frequent user of Steemit and you have some coding skills, you may have wondered how you can write your own features and contribute.

This tutorial shows how you can make modifications to condenser, which is the front-end application of Steemit, the UI of https://steemit.com.

You will learn how to write a feature which pulls data from an external API, and renders it using server-side rendering and also client-side updating.

Prerequisites

  • Github account
  • condenser repository is cloned and set up, following the instructions at https://github.com/steemit/condenser.
  • JavaScript experience. React and Redux experience will be helpful, and so will some experience with Node.js.

Writing the feature

We will be using data from the National Weather Service to display a weather forecast in the sidebar. An advantage of using this API for learning purposes is that it is freely available and does not require a token.

You can try it out in your Terminal, or by visiting the URL in your browser. Making a request to the following URL returns a 7-day forecast for Osage, KS:

curl https://api.weather.gov/gridpoints/TOP/31,80/forecast

Fetching and storing the data

First thing we will do is write a class on the server-side to pull in the data. Then we will cache it, and finally we will store it in the Redux store, so it is available for rendering.

In the condenser repository, create a new file, src/server/utils/Weather.js and place the following in it:

import * as config from 'config';
import axios from 'axios';
import NodeCache from 'node-cache';

export function Weather() {
    this.cache = new NodeCache();
    this.refresh();
}

Weather.prototype.refresh = async function() {
    return await axios({
        url: config.weather_endpoint,
    })
        .then(response => {
            console.info('Received weather data from endpoint...' response.data);
            this.cache.set('weather', response.data, (err, success) => {
                if (err) {
                    rej(err);
                    return;
                
                console.info('Weather data refreshed...');
            });
        })
        .catch(err => {
            console.error('Could not fetch weather data', err);
        });
};

Weather.prototype.get = async function() {
    return new Promise((res, rej) => {
        this.cache.get('weather', (err, value) => {
            if (err) {
                console.error('Could not retrieve Steem Market data');
                res({});
                return;
            
            res(value || {});
        });
    });
};

What this does is provide a JavaScript class called Weather, with a refresh method, which fetches data from the API when called, caches it, and returns a promise. This will allow us to wait until it is done, and it is generally a useful practice to return a promise from a method which performs asynchronous actions.

In order to use this class, we will need to define an environment variable. To do this, add a key "weather_endpoint" with a value of null, to config/default.json, and then add a key "weather_endpoint" with a value of "WEATHER_ENDPOINT" to config/custom-environment-variables.json. Wherever we access config.weather_endpoint in our code, it will attempt to find the value in the WEATHER_ENDPOINT environment variable, if it is defined, otherwise it will default to null, disabling the weather feature. This is a good practice for third parties who want to run their own condenser instances.

Next, let's wire it up and make sure it works. In src/server/server.js, look for the line // some redirects and help status. We will be defining a "Koa middleware" here, which will inject the weather data into server request objects, so we can use it for server-side and client-side rendering:

// Fetch cached weather data for homepage
const weather = new Weather();
app.use(function*(next) {
    this.weatherData = yield weather.get();
    yield next;
});

And add the following to the top of the file:

import { Weather } from './utils/Weather';

Now, run the server (if it is already running, hit ctrl-c to stop it):

WEATHER_ENDPOINT='https://api.weather.gov/gridpoints/TOP/31,80/forecast' \
  npm run start

You should see our console.info calls output after the server starts.

Let's store the weather data in the Redux store so we can use it. In src/server/app_render.jsx, find a line like const initial_state = {. In the nested "app" object, add the key "weatherEndpoint" with a value of config.weather_endpoint, and the key "weatherData" with a value of ctx.weatherData.

If you load http://localhost:8080, and open the console tab of your inspector, you should see a line beginning with "Initial state", and clicking into it, you should be able to see your weather data.

Server-side rendering the data

In order to allow for the site to be rendered on the server-side or on the client-side, the Redux store was made available on both sides. This means that our weather data is already available anywhere we need to render a component.

Let's define a component. Make a file, src/app/components/elements/Weather.jsx, and put the following in it:

import React, { Component } from 'react';
import { connect } from 'react-redux';
// import * as appActions from 'app/redux/AppReducer';

class Weather extends Component {
    render() {
        const weatherData = this.props.weatherData;
        if (!weatherData) {
            return;
        

        // if (this.props.weatherLoading) {
        //     return (
        //         <div>
        //             <p>Loading...</p>
        //         </div>
        //     );
        // }

        const previous = this.props.previous;
        const next = this.props.next;
        const refresh = this.props.refresh;
        const today = weatherData.properties.periods[0];
        const startTime = new Date(today.startTime).toLocaleString();
        const detailedForecast = today.detailedForecast;
        return (
            <div className="c-sidebar__module">
                <div className="c-sidebar__header">
                    <h3 className="c-sidebar__h3">Weather</h3>
                </div>
                <div className="c-sidebar__content">
                    <div className="weather">
                        <h4>Osage, KS</h4>
                        <p>{startTime}</p>
                        <p>{detailedForecast}</p>
                        <button className="button" onClick={refresh}>
                            Refresh
                        </button>
                    </div>
                </div>
            </div>
        );
    
}

export default connect(
    (state, ownProps) => {
        const weatherData = state.app.get('weatherData').toJS();
        return {
            weatherData,
            ...ownProps,
        };
    },
    dispatch => ({
        refresh: () => {
            // console.info('Refresh...');
            // dispatch(appActions.refreshWeather());
        },
    })
)(Weather);

The commented-out lines will be eventually uncommented, but leave them as-is for now.

In order to show the component, we need to put it somewhere. Let's put it in the sidebar. Open src/app/components/pages/PostsIndex.jsx, and within the <aside className="c-sidebar c-sidebar--right"></aside> tag, place <Weather />.

Hit rs in the Terminal window running condenser, and hit enter. This will reload all the code on the server-side. This is only necessary when making changes which affect the server-side.

Reload the page, and you should see the content in the sidebar.

Let's add some styling. Make a new file, src/app/components/elements/Weather.scss, and place the following inside:

.weather {
  .button {
    background-color: #09d6a8;
  
}

In order for the app to find it, add the following somewhere in src/app/components/all.scss:

@import "./elements/Weather";

Restart the server with rs, and reload the page. The button should now be styled.

Using Redux to update the component client-side, part I

We've pretty much avoided Redux up to this point. Redux is a library which works with React in order to hold all of the data the app uses in one place, and provides some guidelines for writing code to allow the user to perform actions. Redux is used for all state management in condenser, so knowing it will be very helpful if you are interested in writing your own features in condenser.

First thing we want to do is to define an action which can be dispatched from our component, when the user clicks the "Refresh" button. In src/app/redux/AppReducer.js, near the top of the file, add:

export const REFRESH_WEATHER = 'app/REFRESH_WEATHER';
export const REFRESHED_WEATHER = 'app/REFRESHED_WEATHER';

In the same file, find where all the case statements live, and add the following to the bottom of those case statements:

case REFRESH_WEATHER:
    return state.set('weatherLoading', true); // saga
case REFRESHED_WEATHER:
    return state
        .set('weatherLoading', false)
        .set('weatherData', Map(action.payload));

Near the bottom of the file, add the following functions which provide a more developer-friendly way to generate these actions:

export const refreshWeather = () => ({
    type: REFRESH_WEATHER,
});

export const refreshedWeather = payload => ({
    type: REFRESHED_WEATHER,
    payload,
});

This does not actually do the work of making the network request. This merely defines the "state changes" the component goes through when "Refresh" is clicked. This will be good enough to demonstrate that things are working, though we will wire the rest up shortly.

Uncomment the commented-out code in src/app/components/elements/Weather.jsx. Reload the page. Clicking "Refresh" should cause the component to show "Loading..." text instead of its usual content.

Using Redux to update the component client-side, part II

One confusing topic for many folks who use React and Redux is, "what is the best way to handle asynchronous behavior?". There are a number of libraries which will help make your Redux code easier to read and maintain. condenser happens to use a library called redux-saga. Although redux-saga is very feature-rich, it can be understood as a way of passively wiring up asynchronous code which runs sequentially aside a Redux action.

redux-saga is used everywhere in condenser, primarily to perform network requests, but also for storing data in LocalStorage, handling authentication, and a number of other purposes.

In this case, we will use redux-saga to fetch new data from the API and change the app state so the weather component no longer shows as loading.

Create a new file, src/app/redux/AppSaga.js, and add the following:

import axios from 'axios';
import * as appActions from 'app/redux/AppReducer';
import { put, select, takeLatest } from 'redux-saga/effects';

export const appWatches = [
    takeLatest(appActions.REFRESH_WEATHER, refreshWeather),
];

function* refreshWeather() {
    console.info('AppSaga.refreshWeather');

    const weatherEndpoint = yield select(state =>
        state.app.get('weatherEndpoint')
    );
    const response = yield axios({
        url: weatherEndpoint,
    });

    yield put(appActions.refreshedWeather(response.data));
}

You will immediately notice some strange syntax. function* defines a "generator function". All this really means is that this function can be paused at a certain statement, and later resumed where it left off. yield hands control away from the function, causing it to pause (although other code outside the function continues to run). redux-saga lets you yield promises, which will cause the function to resume when the promise is fulfilled. In my opinion, this is the core of what redux-saga does.

Why does redux-saga use generators instead of async functions? It's a deep topic, but you can read more here. In short, generators are more expressive, allowing for synchronous as well as asynchronous operations to be yielded, among other subtle differences.

Wiring up the saga is simple. Open up src/shared/RootSaga.js and, near the top, add:

import { appWatches } from 'app/redux/AppSaga';

Inside of yield all([...]), add:

...appWatches,

If you reload the page and click refresh, you should see a network request in the inspector, and you may see the content of the component change.

Contributions Welcome

Now that you have a better idea of how to create a feature, we hope you’ll head on over to our repo and contribute some amazing code!

The Steemit Team

Sort:  

@steemitdev,
It's better limiting the features posts to 3!

Cheers~

Nice tutorial, thanks heaps.

Can you guys make tutorial for adding a brand new page to the Condenser? Like a new route, /weather for example. I was trying to add a new page and replicated one of the existing pages but when I access it’s URL I get an error 505 and the terminal shows a type error, something related to store and user account.

I second this request. Adding new page to the condenser would be nice.

And choices to themes would be excellent. Check out SteemPeak.com website as inspiration.

I think we will start to see many new features now

Posted using Partiko Android

Thanks, for this tutorial ! makes me sort of want to program a bit :)

We don't need a weather widget guys.

Crank out those SMT!

Hopefully this is a joke because we DO need these concise tutorials guiding community devs for submitting proper contributions. Those, paired with the SPS, can greatly reduce dependence on SteemIt.

If you want to at with SMT apparently SCOTbot is suitable for now.

SMT will not have the drastic effect on STEEM I think you are hoping for.

My hopes are long gone.

SMTs won't have what? Long term this blockchain is useless without SMTs. It will be outcompeted by other blockchains who have a better team behind it.

Resteemed this article. Thank you for supporting Steem community.

Congratulations @steemitdev! You received a personal award!

Happy Birthday! - You are on the Steem blockchain for 3 years!

You can view your badges on your Steem Board and compare to others on the Steem Ranking

Do not miss the last post from @steemitboard:

SteemFest⁴ - Meet the Steemians Contest
Vote for @Steemitboard as a witness to get one more award and increased upvotes!