Support for resource nesting
Asked Answered
B

4

7

I am wondering, is it possible to configure DataProvider/Resource/List to support REST urls like api/users/1/roles?

For RESTful API it is very common use case to get childs of certain parent entity, but I cant figure it how to setup React Admin and achieve this. I am using custom DataProvider build on OData spec backend.

I understand that I can get roles of certain user by filtered request on api/roles?filter={userId: 1} or something like that, but my issue is that my users and roles are in many-to-many relation so relation references are stored in pivot table. In other words, I dont have reference on user in roles table so I cant filter them.

Am I overseeing something or is there some approach which I simply dont see?

EDIT: REST API is built in OData spec and it supports many-to-many relations with classic pivot (or intermediate) table. This table is not exposed in API, but is utilized in urls like the one above. So I cant directly access it as a resource.

Schema for User - Role relations looks pretty standard too.

|----------|    |-----------|     |--------|
| USER     |    | User_Role |     | Role   |
|----------|    |-----------|     |--------|
| Id       |-\  | Id        |   /-| Id     |
| Login    |  \-| UserId    |  /  | Name   |
| Password |    | RoleId    |-/   | Code   |
|----------|    |-----------|     |--------|
Bream answered 18/1, 2019 at 13:30 Comment(1)
Can you specify your DB design, so I could understand the question better? I'm not familiar with react-admin but I could tell you how the REST API might look. Can you change you DB design?Polyandry
P
10

TL;DR: By default, React Admin doesn't support nested resource, you have to write a custom data provider.

This question was answered on a past issue: maremelab/react-admin#261

Detailed Answer

The default data provider in React Admin is ra-data-simple-rest.

As explained on its documentation, this library doesn't support nested resources since it only use the resource name and the resource ID to build a resource URL:

Simple REST Data Provider

In order to support nested resources, you have to write your own data provider.

Nested resources support is a recurrent feature request but, at the time, the core team don't want to handle this load of work.

I strongly suggest to gather your forces and write an external data provider and publish it like ra-data-odata provider. It would be a great addition and we will honored to help you with that external package.

Phillip answered 18/1, 2019 at 13:30 Comment(5)
Thanks for your reply, I am already almost done with custom data provider for OData dialect. To make it really work I need to implement nested resources, better many-to-many reference fields and inputs. Right now I feel kind of discouraged because I dont feel like I am up to the task. As you said, it is load of work. I dont want to promise anything but I want to continue with this awesome project so eventually I will have to fork react-admin and extend it instead of finding workarounds which I hate doing. But for now I have to compromise. Thanks again for your reply :)Bream
Yes, it is! Do you have a starting example, an OSS repository? If you want, we can help you redirect the volunteers to your repo.Phillip
For now I have private repository, but I dont mind fork it to Github. Right now its kinda dirty because of workarounds but I can publish older version of my custom odata provider which works fine with filters, sorting and all this stuff expect for nested resources and many-to-many relations. For simple OData REST API it should be fineBream
It's a good start for a 0.1 version. Feel free to create a repo on GitHub, and create an issue where you'll ping me @PhillipPhillip
it's possible as of react-admin v4 marmelab.com/react-admin/Resource.html#nested-resourcesOmmiad
R
4

Your question was already answer here, but I would like to tell you about my workaround in order for React-Admin work with many-to-many relations.

As said in the mentioned answer you have to extend the DataProvider in order for it to fetch resources of a many-to-many relation. However you need to use the new REST verb, lets suppose GET_MANY_MANY_REFERENCE somewhere on your application. Since different REST services/API can have different routes formats to fetch related resources I didn't bother trying to build a new DataProvider, I know is not a great solution, but for short deadlines is considerable simple.

My solution was taking inspiration on <ReferenceManyField> and build a new component <ReferenceManyManyField> for many-to-many relations. This component fetches related records on componentDidMount using fetch API. On response uses the response data to build to objects one data being an object with keys being record ids, and values the respective record object, and an ids array with the ids of records. This is passes to children along with other state variables like page, sort, perPage, total, to handle pagination and ordering of data. Be aware that changing the order of the data in a Datagrid means a new request will be made to the API. This component is divided in a controller and a view, like <ReferencemanyField>, where controller fetches data, manages it and passes it to children and view that receives controller data and passes it to children render its content. That made me possible to render many-to-many relations data on a Datagrid, even if with some limitation, is a component to aggregated to my project and only work with my current API if something changes I would have to change the field to, but as for now it works and can be reused along my app.

Implementation details go as follow:

//ReferenceManyManyField
export const ReferenceManyManyField = ({children, ...prop}) => {
  if(React.Children.count(children) !== 1) {
    throw new Error( '<ReferenceManyField> only accepts a single child (like <Datagrid>)' )
  }

  return <ReferenceManyManyFieldController {...props}>
    {controllerProps => (<ReferenceManyManyFieldView 
    {...props} 
    {...{children, ...controllerProps}} /> )}
  </ReferenceManyManyFieldController>

//ReferenceManyManyFieldController
class ReferenceManyManyFieldController extends Component {

  constructor(props){
    super(props)
    //State to manage sorting and pagination, <ReferecemanyField> uses some props from react-redux 
    //I discarded react-redux for simplicity/control however in the final solution react-redux might be incorporated
    this.state = {
      sort: props.sort,
      page: 1,
      perPage: props.perPage,
      total: 0
    }
  }

  componentWillMount() {
    this.fetchRelated()
  }

  //This could be a call to your custom dataProvider with a new REST verb
  fetchRelated({ record, resource, reference, showNotification, fetchStart, fetchEnd } = this.props){
    //fetchStart and fetchEnd are methods that signal an operation is being made and make active/deactivate loading indicator, dataProvider or sagas should do this
    fetchStart()
    dataProvider(GET_LIST,`${resource}/${record.id}/${reference}`,{
      sort: this.state.sort,
      pagination: {
        page: this.state.page,
        perPage: this.state.perPage
      }
    })
    .then(response => {
      const ids = []
      const data = response.data.reduce((acc, record) => {
        ids.push(record.id)
        return {...acc, [record.id]: record}
      }, {})
      this.setState({data, ids, total:response.total})
    })
    .catch(e => {
      console.error(e)
      showNotification('ra.notification.http_error')
    })
    .finally(fetchEnd)
  }

  //Set methods are here to manage pagination and ordering,
  //again <ReferenceManyField> uses react-redux to manage this
  setSort = field => {
    const order =
        this.state.sort.field === field &&
        this.state.sort.order === 'ASC'
            ? 'DESC'
            : 'ASC';
    this.setState({ sort: { field, order } }, this.fetchRelated);
  };

  setPage = page => this.setState({ page }, this.fetchRelated);

  setPerPage = perPage => this.setState({ perPage }, this.fetchRelated);

  render(){
    const { resource, reference, children, basePath } = this.props
    const { page, perPage, total } = this.state;

    //Changed basePath to be reference name so in children can nest other resources, not sure why the use of replace, maybe to maintain plurals, don't remember 
    const referenceBasePath = basePath.replace(resource, reference);

    return children({
      currentSort: this.state.sort,
      data: this.state.data,
      ids: this.state.ids,
      isLoading: typeof this.state.ids === 'undefined',
      page,
      perPage,
      referenceBasePath,
      setPage: this.setPage,
      setPerPage: this.setPerPage,
      setSort: this.setSort,
      total
    })
  }

}

ReferenceManyManyFieldController.defaultProps = {
  perPage: 25,
  sort: {field: 'id', order: 'DESC'}
}

//ReferenceManyManyFieldView
export const ReferenceManyManyFieldView = ({
  children,
  classes = {},
  className,
  currentSort,
  data,
  ids,
  isLoading,
  page,
  pagination,
  perPage,
  reference,
  referenceBasePath,
  setPerPage,
  setPage,
  setSort,
  total
}) => (
  isLoading ? 
    <LinearProgress className={classes.progress} />
  :
      <Fragment>
        {React.cloneElement(children, {
          className,
          resource: reference,
          ids,
          data,
          basePath: referenceBasePath,
          currentSort,
          setSort,
          total
        })}
        {pagination && React.cloneElement(pagination, {
          page,
          perPage,
          setPage,
          setPerPage,
          total
        })}
      </Fragment>
);

//Assuming the question example, the presentation of many-to-many relationship would be something like
const UserShow = ({...props}) => (
  <Show {...props}>
    <TabbedShowLayout>
      <Tab label='User Roles'>
        <ReferenceManyManyField source='users' reference='roles' addLabel={false} pagination={<Pagination/>}>
          <Datagrid>
            <TextField source='name'/>
            <TextField source='code'/>
          </Datagrid>
        </ReferenceManyManyField>
      </Tab>
    </TabbedShowLayout>
  </Show>
)
//Used <TabbedShowLayout> because is what I use in my project, not sure if works under <Show> or <SimpleShowLayout>, but I think it work since I use it in other contexts

I think the implementation can be improved and be more compatible with React-Admin. In other reference fields data fetch is stored on react-redux state, in this implementation it's not. The relation is not saved anywhere besides the component making application not work on offline since can't fetch data, not even ordering is possible.

Regeneration answered 22/1, 2019 at 11:15 Comment(5)
On the contrary, I think your workaround is how implementation of many-to-many relation to react-admin would look like. Nevertheless thanks for sharing your idea, it's been very helpful to realize what needs to be done.Bream
anyway, would you mind if I take a quick look on your solution? Maybe we can together come up with extension to already awesome react-admin :)Bream
Nice approach! That's why we heavy rely on customization, that way creative users can do clever things. :)Phillip
Added implementation of what I have and an usage example. Hope it helps for now, but I really think it needs an update and more serious thinking about how it works with React-Admin.Eminence
Should an issue be open in source code to debate how the implementation can be done?Eminence
A
4

Had a very similar question. My solution was more of a hack but a little simpler to implement if all you want is to enable a ReferenceManyField. Only the dataProvider needs to be modified:

I'm repeating my solution here modified for the current question:

Using the stock ReferenceManyField:

<Show {...props}>
    <TabbedShowLayout>
        <Tab label="Roles">
            <ReferenceManyField reference="roles" target="_nested_users_id" pagination={<Pagination/>} >
                <Datagrid>
                    <TextField source="role" />
                </Datagrid>
            </ReferenceManyField>
        </Tab>
    </TabbedShowLayout>
</Show>

I then modified my dataProvider, which is a fork of ra-jsonapi-client. I changed index.js under the case GET_MANY_REFERENCE from this:

      // Add the reference id to the filter params.
      query[`filter[${params.target}]`] = params.id;

      url = `${apiUrl}/${resource}?${stringify(query)}`;

to this:

      // Add the reference id to the filter params.
      let refResource;
      const match = /_nested_(.*)_id/g.exec(params.target);
      if (match != null) {
        refResource = `${match[1]}/${params.id}/${resource}`;
      } else {
        query[`filter[${params.target}]`] = params.id;
        refResource = resource;
      }

      url = `${apiUrl}/${refResource}?${stringify(query)}`;

So basically I just remap the parameters to the url for the special case where the target matches a hard coded regex.

ReferenceManyField would normally have caused the dataProvider to call api/roles?filter[_nested_users_id]=1 and this modification makes the dataProvider call api/users/1/roles instead. It is transparent to react-admin.

Not elegant but it works and doesn't seem to break anything on the front end.

Aquila answered 6/7, 2019 at 2:29 Comment(0)
M
0

With the new release of March 2023, React-Admin added support for nested routes, see: https://marmelab.com/blog/2023/03/01/react-admin-march-2023-update.html#nested-resource-urls.

You can nest resources directly in the :

import { Admin, Resource } from 'react-admin'; import { Route } from 'react-router-dom';

export const App = () => (
    <Admin dataProvider={dataProvider}>
        <Resource name="artists" list={ArtistList} edit={ArtistDetail}>
            <Route path=":id/songs" element={<SongList />} />
            <Route path=":id/songs/:songId" element={<SongDetail />} />
        </Resource>
    </Admin>
);

The views for nested resources need a bit of extra work to grab the parameters from the URL via react-router's useParams. For instance, for the songs list:

// in src/SongList.jsx
import { List, Datagrid, TextField, useRecordContext } from 'react-admin';
import { useParams } from 'react-router-dom';
import { Button } from '@mui/material';

export const SongList = () => {
    const { id } = useParams();
    return (
        <List resource="songs" filter={{ artistId: id }}>
            <Datagrid rowClick="edit">
                <TextField source="title" />
                <DateField source="released" />
                <TextField source="writer" />
                <TextField source="producer" />
                <TextField source="recordCompany" label="Label" />
                <EditSongButton />
            </Datagrid>
        </List>
    );
};

const EditSongButton = () => {
    const song = useRecordContext();
    return (
        <Button
            component={Link}
            to={`/artists/${song?.artist_id}/songs/${song?.id}`}
            startIcon={<EditIcon />}
        >
            Edit
        </Button>
    );
};

And for the edition view of a song:

// in src/SongDetail.jsx
import { Edit, SimpleForm, TextInput } from 'react-admin';
import { useParams } from 'react-router-dom';

export const SongDetail = () => {
    const { id, songId } = useParams();
    return (
        <Edit resource="posts" id={songId} redirect={`/artists/${id}/songs`}>
            <SimpleForm>
                <TextInput source="title" />
                <DateInput source="released" />
                <TextInput source="writer" />
                <TextInput source="producer" />
                <TextInput source="recordCompany" label="Label" />
            </SimpleForm>
        </Edit>
    );
};
Minorca answered 29/8, 2023 at 12:37 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.