How do I get a users fullname in Volto

In the following example I get the fullname of the author of an item using the action getUser(userId) to show it in the view. That works fine but only if I am logged in. How do I show it for anonymous users as well?

And another question: How do I find out that the user-data will end up in state.users.user and not in state.user or in state.deadparrot?

/**
 * NewsItemView view component.
 * @module components/theme/View/NewsItemView
 */

import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import RenderBlocks from '@plone/volto/components/theme/View/RenderBlocks';
import config from '@plone/volto/registry';
import { getUser } from '@plone/volto/actions';
import { useDispatch, useSelector } from 'react-redux';

/**
 * NewsItemView view component class.
 * @function NewsItemView
 * @params {object} content Content object.
 * @returns {string} Markup of the component.
 */
const NewsItemView = ({ content }) => {
  const Container = config.getComponent({ name: 'Container' }).component;
  const dispatch = useDispatch();
  const userId = content?.creators ? content.creators[0] : '';
  const user = useSelector((state) => state.users.user);
  useEffect(() => {
    dispatch(getUser(userId));
  }, [dispatch, userId]);

  return (
    <Container id="page-document" className="view-wrapper newsitem-view">
      {userId && <div className="author">{user?.fullname || userId}</div>}
      <RenderBlocks content={content} />
    </Container>
  );
};

/**
 * Property types.
 * @property {Object} propTypes Property types.
 * @static
 */
NewsItemView.propTypes = {
  content: PropTypes.shape({
    title: PropTypes.string,
    description: PropTypes.string,
    text: PropTypes.shape({
      data: PropTypes.string,
    }),
  }).isRequired,
};

export default NewsItemView;

(Answering second question) I think this is the place where that info is written to the store:

It's the users reducer, so it's state.users. The reducer functions and their names are declared here:

based on tracking this from here:

3 Likes

I found the answer to the first question. The permission plone.restapi: Access Plone user information is used in the @users endpoint that is used by the action getUser. By default that is only granted to Manager (not even Site Administrator).
If I grant that permission to Anonymous it works.

Doing that can obviously be a security-problem depending on your use-case.
I will probably overwrite the endpoint to only return fullname and home_page in case the user is not a Manager.

2 Likes

Interesting... I'm facing a similar situation, except, I want to check for the user's roles.
I don't want to give anonymous that much control of the @users endpoint.
Perhaps I need a simple @myroles endpoint that reports a user's current roles

Hey future self, I see you're back on this thread again. You're probably looking for a good pattern to retrieve fullnames in the context of some kind of volto listing/search block.

The Problem:

When iterating over items in a listing view, item.listCreators mostly returns an array of Userids not fullnames. You'll need a way to convert them into fullnames.

As @pbauer points out, getUser is not a viable option as only managers can use it with the default permissions.

The Solution:

  1. Create a custom endpoint that returns only the fullname and id
  2. Call the new endpoint from my volto block (instead of getUser)

The endpoint looks like this:
++api++/@user-name/{userid}
which returns:

{
"fullname":<fullname>
"id":<userid>
}

Added in a restapi folder of my backend addon

│  
├── restapi
│   └── configure.zcml
│   └── username.py

username.py looks like this:

from plone.restapi.services import Service
from zope.interface import implementer
from zope.publisher.interfaces import IPublishTraverse
from plone import api
from plone.restapi.deserializer import json_body
from plone.restapi.interfaces import ISerializeToJson

@implementer(IPublishTraverse)
class UserNameGet(Service):
    def __init__(self, context, request):
        super(UserNameGet, self).__init__(context, request)
        self.params = []

    def publishTraverse(self, request, name):
        self.params.append(name)
        return self

    def reply(self):
        if len(self.params) != 1:
            self.request.response.setStatus(400)
            return {"error": "Must supply exactly one user ID"}

        user_id = self.params[0]
        user = api.user.get(username=user_id)

        if user is None:
            self.request.response.setStatus(404)
            return {"error": f"User {user_id} not found"}

        return {
            "id": user_id,
            "fullname": user.getProperty('fullname') or user_id
        }

I register it in the configure.zcml

<configure
    xmlns="http://namespaces.zope.org/zope"
    xmlns:plone="http://namespaces.plone.org/plone"
    xmlns:zcml="http://namespaces.zope.org/zcml">

   <plone:service
    method="GET"
    name="@user-name"
    for="zope.interface.Interface"
    factory=".username.UserNameGet"
    permission="zope2.View"
    />

</configure>

Using the new endpoint in my custom volto view

In my case, this is a listing variation and I needed to make user of my new endpoint.
I'm doing it in a "bad" way (I'm still a bit allergic to the action/reducer/types "dance", I plan to get over that at some point).

For now, I'm not relying on redux for this part (not sure if that's a bad decision). Instead I'm calling the endpoint directly from my view.

Here are the key things

  1. there's a new state creatorNames to store the mapping of user IDs to full names.
  2. The useEffect hook includes an asynchronous function fetchUserNames that:
    • Collects all unique creator IDs from all items.
    • Fetches the full name for each creator ID using the new @user-name endpoint.
    • Updates the creatorNames state with the fetched names.
  3. The getCreatorName function returns the name from the creatorNames state, falling back to the user ID if not found.

The full code is below (less the css, ignore the use of react-icons, had to yarn add react-icons)

import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { Card, Grid, List, Button } from 'semantic-ui-react';
import { FaFilePdf } from 'react-icons/fa';
import moment from 'moment';
import { getUser } from '@plone/volto/actions';
import './gridlist.css';

const GridListTemplate = ({ items }) => {
  const dispatch = useDispatch();
  const userInfo = useSelector((state) => state.users.users);
  const [creatorNames, setCreatorNames] = useState({});

  useEffect(() => {
    const fetchUserNames = async () => {
      const allCreators = [
        ...new Set(items.flatMap((item) => item.listCreators || [])),
      ];
      const newCreatorNames = { ...creatorNames };

      for (const userId of allCreators) {
        if (!creatorNames[userId]) {
          try {
            const response = await fetch(`/++api++/@user-name/${userId}`);
            if (response.ok) {
              const data = await response.json();
              newCreatorNames[userId] = data.fullname || userId;
            } else {
              newCreatorNames[userId] = userId;
            }
          } catch (error) {
            console.error(`Error fetching name for user ${userId}:`, error);
            newCreatorNames[userId] = userId;
          }
        }
      }

      setCreatorNames(newCreatorNames);
    };

    fetchUserNames();
  }, [items]);

  const getCreatorName = (userId) => {
    return creatorNames[userId] || userId;
  };

  return (
    <Grid columns={2}>
      {items.map((item, index) => {
        const publicationDate = item.Date
          ? moment(item.Date).format('MMMM D, YYYY')
          : 'No publication date';
        const documentType =
          item.document_type || item['Type'] || item['@type'];
        const folderName =
          item.parentTitle ||
          (item['@id']
            ? item['@id'].split('/').slice(-2, -1)[0]
            : 'Unknown folder');
        const contributors = item.listCreators || [];

        return (
          <Grid.Column key={`item-index-${index}`}>
            <Card fluid>
              <Card.Content className="grid-list-content">
                <Card.Header>
                  {item.review_state === 'internally_published' && (
                    <div className="grid-list-sash">Members Only</div>
                  )}
                  <h4 className="grid-label">{documentType}</h4>
                  <h3 className="grid-list-location">{folderName}</h3>
                  <a
                    className="grid-list-heading"
                    title={item.title}
                    href={item.getURL}
                  >
                    {item.title}
                  </a>
                </Card.Header>
                <Card.Meta>
                  by
                  <span className="list-creators">
                    {' '}
                    {contributors.map(getCreatorName).join(', ')}
                  </span>
                  {publicationDate}
                </Card.Meta>
                <Card.Description className="grid-list-description">
                  {item.description}
                </Card.Description>
                {item.hasFile && (
                  <Card.Content extra className="grid-list-footer">
                    <a href={`${item.getURL}/@@display-file/file`}>
                      <FaFilePdf
                        className="grid-list-icon"
                        style={{ marginRight: '5px' }}
                      />
                    </a>
                    {item.countChildren > 0 && (
                      <>
                        <span> &</span>
                        <Button
                          as="a"
                          href={item.getURL}
                          size="small"
                          className="grid-list-button"
                          style={{ marginLeft: '10px' }}
                        >
                          Supporting Materials ({item.countChildren})
                        </Button>
                      </>
                    )}
                  </Card.Content>
                )}
              </Card.Content>
            </Card>
          </Grid.Column>
        );
      })}
    </Grid>
  );
};

GridListTemplate.propTypes = {
  items: PropTypes.arrayOf(PropTypes.any).isRequired,
  linkMore: PropTypes.any,
  isEditMode: PropTypes.bool,
};

export default GridListTemplate;

1 Like

My apologies for the "ugly" version.
After reacquainting myself with redux and how it is used with Volto, I implemented the "correct" version.
Steps involved:

  1. Register the new reducer, action and type
  2. Update my volto config.js to add the userfullname reducer.
  3. Use the new getUserName action in my code

Step 1 - Register reducer, action and action type

Here is my userfullname.js reducer file:
Located at:

reducers
├── index.js
└── userfullname
    └── userfullname.js <-------------------

import { GET_USER_NAME } from '../../constants/ActionTypes';

const initialState = {
  userNames: {}, // <---- the userNames state is going to be important moving forward
};

export default function users(state = initialState, action = {}) {
  switch (action.type) {
    case `${GET_USER_NAME}_SUCCESS`:
      return {
        ...state,
        userNames: {
          ...state.userNames,
          [action.request.path.split('/').pop()]: action.result.fullname,
        },
      };
    default:
      return state;
  }
}

The ActionTypes.js is located under the constants folder

constants
└── ActionTypes.js

and look like this:

// User actions
export const GET_USER_NAME = 'GET_USER_NAME';
export const GET_USER_NAME_SUCCESS = 'GET_USER_NAME_SUCCESS';
export const GET_USER_NAME_FAILURE = 'GET_USER_NAME_FAILURE';

Here is the userfullname.js file with the action, this maps the location of the actual endpoint:

actions
├── index.js
└── userfullname.js <-------
import { GET_USER_NAME } from '../constants/ActionTypes';

export function getUserName(userId) {
  return {
    type: GET_USER_NAME,
    request: {
      op: 'get',
      path: `/@user-name/${userId}`,
    },
  };
}

Step 2 - Update the Volto config.js

Here are the changes to the config.js


// the key changes...
import userfullname from './reducers/users/userfullname';

export default function applyConfig(config) {
  // Add here your project's configuration here by modifying `config` accordingly

  /*




  */
  config.addonReducers.userfullname = userfullname; // <---- implied here is the userNames state
//...
  return config;
}

Step 3 - Use the new action and reducer with userNames state in my code

Note that we need both the action and the reducer (in the form of the userNames state, retrieved using useSelector)

import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { Card, Grid, List, Button } from 'semantic-ui-react';
import { FaFilePdf } from 'react-icons/fa';
import moment from 'moment';
import { getUserName } from '../../../../actions/userfullname';
import './gridlist.css';

const GridListTemplate = ({ items }) => {
  const dispatch = useDispatch();
  const userNames = useSelector((state) => state.userfullname.userNames); <--- and this is where we make use of the userNames state

  useEffect(() => {
    const allCreators = [
      ...new Set(items.flatMap((item) => item.listCreators || [])),
    ];

    allCreators.forEach((userId) => {
      if (!userNames[userId]) {
        dispatch(getUserName(userId));
      }
    });
  }, [items, userNames, dispatch]);

  return (
    <Grid columns={2}>
      {items.map((item, index) => {
        const publicationDate = item.Date
          ? moment(item.Date).format('MMMM D, YYYY')
          : 'No publication date';
        const documentType =
          item.document_type || item['Type'] || item['@type'];
        const folderName =
          item.parentTitle ||
          (item['@id']
            ? item['@id'].split('/').slice(-2, -1)[0]
            : 'Unknown folder');
        const contributors = item.listCreators || [];

        return (
          <Grid.Column key={`item-index-${index}`}>
            <Card fluid>
              <Card.Content className="grid-list-content">
                <Card.Header>
                  {item.review_state === 'internally_published' && (
                    <div className="grid-list-sash">Members Only</div>
                  )}
                  <h4 className="grid-label">{documentType}</h4>
                  <h3 className="grid-list-location">{folderName}</h3>
                  <a
                    className="grid-list-heading"
                    title={item.title}
                    href={item.getURL}
                  >
                    {item.title}
                  </a>
                </Card.Header>
                <Card.Meta>
                  by
                  <span className="list-creators">
                    {' '}
                    {contributors
                      .map((userId) => userNames[userId] || userId)
                      .join(', ')}
                  </span>
                  {publicationDate}
                </Card.Meta>
                <Card.Description className="grid-list-description">
                  {item.description}
                </Card.Description>
                {item.hasFile && (
                  <Card.Content extra className="grid-list-footer">
                    <a href={`${item.getURL}/@@display-file/file`}>
                      <FaFilePdf
                        className="grid-list-icon"
                        style={{ marginRight: '5px' }}
                      />
                    </a>
                    {item.countChildren > 0 && (
                      <>
                        <span> &</span>
                        <Button
                          as="a"
                          href={item.getURL}
                          size="small"
                          className="grid-list-button"
                          style={{ marginLeft: '10px' }}
                        >
                          Supporting Materials ({item.countChildren})
                        </Button>
                      </>
                    )}
                  </Card.Content>
                )}
              </Card.Content>
            </Card>
          </Grid.Column>
        );
      })}
    </Grid>
  );
};

GridListTemplate.propTypes = {
  items: PropTypes.arrayOf(
    PropTypes.shape({
      Date: PropTypes.string,
      document_type: PropTypes.string,
      Type: PropTypes.string,
      '@type': PropTypes.string,
      parentTitle: PropTypes.string,
      '@id': PropTypes.string,
      title: PropTypes.string,
      getURL: PropTypes.string,
      listCreators: PropTypes.arrayOf(PropTypes.string),
      review_state: PropTypes.string,
      description: PropTypes.string,
      hasFile: PropTypes.bool,
      countChildren: PropTypes.number,
    }),
  ).isRequired,
  linkMore: PropTypes.any,
  isEditMode: PropTypes.bool,
};

export default GridListTemplate;

For context this is how my Volto project is structured:

src
├── actions <--------- the actions go here
├── addons
├── components
│   ├── Blocks
│        ├── ListingVarations
│        │   ├── CommitteeListTemplate
│        │   ├── GatedContentListTemplate
│        │   ├── GridListTemplate <-------  the `GridListTemplate.jsx` file where all the action happens is in this folder
│        └── ResourceGridTemplate
├── config.js  <--------- where I register the reducer
└── reducers
    └── userfullname <---------- added the `userfullname.js` file here

update: I got into some namespace trouble and had to change the namespace to be userfullname (I was previously using users and this was clashing with something else in the redux app store and clashing with the PersonalTools menu, at the bottom left of the interface, which already relies on the users namespace.

Just for documentation:

ploncli can add restapi_service

Thanks @espenmn that does help to complete the notes. I didn't mention how to register the rest api endpoint in the backend.
I haven't used Plone CLI in a while but I really should!