React Admin Advanced Recipes: User Profile

Gildas Garcia
Gildas GarciaMarch 07, 2019
#react#react-admin#tutorial

This is the fourth article in the series of advanced tutorials for React-admin. It assumes you already understand the basics of react-admin and have at least completed the official tutorial.

Tip: This article was written for react-admin v2. If you're working on a recent react-admin project, you probably want to read the same tutorial updated for react-admin v3.

Sometimes, it is necessary to store some global data about the current user and their preferences for the application. I could add a react-admin Resource for a profile, but from the user's point of view, it does not make sense to access their profile through a list page with a single record and an "edit" button. After all, they will only ever have one profile to edit.

In this article, I'll explain how to create a profile page based on an <Edit> component, and accessible as a standalone page. As usual, I'll work on a fork of the codesandbox that was built in the last article. Below is a screen-cast of the end result:

End result

Handling the Data

First, I have to decide how I will store and load the user's profile. For the purpose of this article, let's store it in the browser local storage.

However, I still want to use the dataProvider to load and update it. Indeed, this will allow me to use all the niceties react-admin provides for my resources: easy forms with validation, notifications, error handling, etc.

For that, I need the profile to be a react-admin resource, and to have an id. I'm sure there will only be a single profile per user. It means I only need to handle the GET_ONE and UPDATE fetch types.

All I have to do is to make the dataProvider handle this special case. The most straightforward way to do it is to decorate the dataProvider, giving me a chance to intercept calls related to the profile resource:

schema about wrapping the dataProvider

import jsonRestProvider from "ra-data-fakerest";
import { GET_ONE, UPDATE } from "react-admin";
import data from "./data";

const disableFakeFetchRequestsLogs = true;

// A function decorating a dataProvider for handling user profiles
const handleUserProfile = dataProvider => (verb, resource, params) => {
  // I know I only GET or UPDATE the profile as there is only one for the current user
  // To showcase how I can do something completely different here, I'll store it in local storage
  // You can replace this with a customized fetch call to your own API route, too
  if (resource === "profile") {
    if (verb === GET_ONE) {
      const storedProfile = localStorage.getItem("profile");

      if (storedProfile) {
        return Promise.resolve({
          data: JSON.parse(storedProfile),
        });
      }

      // No profile yet, return a default one
      // It's important that I send the same id as requested in params.
      // Indeed, react-admin will verify it and may throw an error if they are different
      // I don't have to do it when the profile exists as it will be included in the data stored in the local storage
      return Promise.resolve({
        data: { id: params.id, nickname: "" },
      });
    }

    if (verb === UPDATE) {
      localStorage.setItem("profile", JSON.stringify(params.data));
      return Promise.resolve({ data: params.data });
    }
  }

  // Fallback to the dataProvider default handling for all other resources
  return dataProvider(verb, resource, params);
};

export default handleUserProfile(
  jsonRestProvider(data, disableFakeFetchRequestsLogs)
);

I can now declare the profile resource in my <Admin />.

const App = () => (
    <Admin
        dataProvider={dataProvider}
    >
        <Resource name="posts" {...posts} />
        <Resource name="comments" {...comments} />
        <Resource name="tags" {...tags} />
+       <Resource name="profile" />
    </Admin>
);

Editing the Profile

Now that my dataProvider is ready, I must set up the UI to edit the profile. I can follow the usual workflow here, starting with the edit component:

// in profile/EditProfile.jso
import React from 'react';
import { Edit, TextInput, SimpleForm, required } from 'react-admin';

const ProfileEdit = ({ staticContext, ...props }) => {
    return (
        <Edit
            redirect={false} // I don't need any redirection here, there's no list page
            {...props}
        >
            <SimpleForm>
                <TextInput source="nickname" validate={required()} />
            </SimpleForm>
        </Edit>
    );
};

export default ProfileEdit;

// in profile/index.js
import ProfileEdit from './ProfileEdit';

export default {
    edit: ProfileEdit
};

Nothing fancy here. But now, how can I provide a way for my users to access this view? By default, React-admin won't include an entry in the menu for resources without a list component.

Fortunately, React-admin supports custom routes, which must be set on the <Admin> component:

+import profile from './profile';

const App = () => (
    <Admin
        dataProvider={dataProvider}
+       customRoutes={[
+           <Route
+               key="my-profile"
+               path="/my-profile"
+               component={profile.edit}
+           />
+       ]}
    >
        <Resource name="posts" {...posts} />
        <Resource name="comments" {...comments} />
        <Resource name="tags" {...tags} />
        <Resource name="profile" />
    </Admin>
);

However, as the route hasn't been created by a <Resource> component, it won't receive the props required by the <Edit> component to do its work: loading and saving a resource specified by its id.

There are 3 such props:

  • resource: This one is easy, I can just pass it profile
  • basePath: This is used for the redirection which happen after the resource is saved. I don't really need it as I disabled the redirection but the component expects it. I'll pass it the custom page path /my-profile.
  • id: This one is trickier. I don't really have an id as I store the profile in local storage and if I had one (from the database), my api might not expose it and just expect the loading (GET) and update (POST or PATCH) request to be made on a https://api.com/me route for example. That means I can pass it whatever I like, my-profile for example.

Let's fix the EditProfile component:

const ProfileEdit = ({ staticContext, ...props }) => {
    return (
        <Edit
            /*
                As this component isn't loaded from a route generated by a <Resource>,
                I have to provide the id myself.
                As there is only one config for the current user, I decided to
                hard-code it here
            */
+           id="my-profile"
            /*
                For the same reason, I need to provide the resource and basePath props
                which are required by the Edit component
            */
+           resource="profile"
+           basePath="/my-profile"
            redirect={false}
            /*
                I also customized the page title as it'll make more sense to the user
            */
+           title="My profile"
            {...props}
        >
            <SimpleForm>
                <TextInput source="nickname" validate={required()} />
            </SimpleForm>
        </Edit>
    );
};

I can now verify that it works by accessing the /my-profile path by myself.

Edit profile page

However, I still don't have provided my users a way to access it.

Accessing the Profile Page

Most apps provide access to the profile through a user menu in the app bar. By default, React-admin will add a user menu with a single logout entry only if there is an authenticated user.

However, it is easy to customize it by supplying my own user menu and app bar components in a custom layout.

I will also fetch the user profile in order to display its nickname:

// in src/MyUserMenu.js
import React, { Component } from 'react';
import { connect } from 'react-redux';
import Typography from '@material-ui/core/Typography';
import { crudGetOne, UserMenu, MenuItemLink } from 'react-admin';
import SettingsIcon from '@material-ui/icons/Settings';

class MyUserMenuView extends Component {
    componentDidMount() {
        this.fetchProfile();
    }

    fetchProfile = () => {
        this.props.crudGetOne(
            // The resource
            'profile',
            // The id of the resource item to fetch
            'my-profile',
            // The base path. Mainly used on failure to fetch the data
            '/my-profile',
            // Whether to refresh the current view. I don't need it here
            false
        );
    };

    render() {
        const { crudGetOne, profile, ...props } = this.props;

        return (
            <UserMenu label={profile ? profile.nickname : ''} {...props}>
                <MenuItemLink
                    to="/configuration"
                    primaryText="Configuration"
                    leftIcon={<SettingsIcon />}
                />
            </UserMenu>
        );
    }
}

const mapStateToProps = state => {
    const resource = 'profile';
    const id = 'my-profile';
    const profileState = state.admin.resources[resource];

    return {
        profile: profileState ? profileState.data[id] : null
    };
};

const MyUserMenu = connect(
    mapStateToProps,
    { crudGetOne }
)(MyUserMenuView);
export default MyUserMenu;

// in src/MyAppBar.js
import React from 'react';
import { AppBar } from 'react-admin';
import MyUserMenu from './MyUserMenu';

const MyAppBar = props => <AppBar {...props} userMenu={<MyUserMenu />} />;
export default MyAppBar;

// in ./MyLayout.js
import React from 'react';
import { Layout } from 'react-admin';
import MyAppBar from './MyAppBar';

const MyLayout = props => <Layout {...props} appBar={MyAppBar} />;

export default MyLayout;

Finally, I have to instruct the Admin component to use my layout:

+import MyLayout from './MyLayout';

const App = () => (
    <Admin
        dataProvider={dataProvider}
        customRoutes={[
            <Route
                key="my-profile"
                path="/my-profile"
                component={profile.edit}
            />
        ]}
+       appLayout={MyLayout}
    >
        <Resource name="posts" {...posts} />
        <Resource name="comments" {...comments} />
        <Resource name="tags" {...tags} />
        <Resource name="profile" />
    </Admin>
);

And voila, I now have a profile page leveraging react-admin features!

End result

Conclusion

The core team wondered whether it would be a good idea to provide defaults for this kind of feature. However, as all applications would probably have different needs for it, we instead ensured react-admin will never get in your way. This is one of many examples.

As usual, you'll find the code for this tutorial in the following codesandbox.

Did you like this article? Share it!