Retrieving a file field in the context of a custom listing variation with Volto

I'm sure it's staring me in the face but....
I've created a content type (a committee) that includes file attachments (vcard)
I'm using a listing block variation to display the committees (committee listing) in volto, but I'm not sure how to return the files in my custom listing view.

This is a simplified version of my model

<model ....>
  <schema>
    <field name="committee_chair_vcard" type="plone.namedfile.field.NamedBlobFile">
      <description/>
      <required>False</required>
      <title>Chair VCard</title>
    </field>
    <field name="committee_vice_chair_vcard" type="plone.namedfile.field.NamedBlobFile">
      <description/>
      <required>False</required>
      <title>Vice Chair VCard</title>
    </field>

    <field name="additional_text" type="plone.app.textfield.RichText">
      <description/>
      <required>False</required>
      <title>Additional Text</title>
    </field>
  </schema>
</model>

This is my listing template (simplified for the sake of the question)

import PropTypes from 'prop-types';
import React from 'react';
import ContactComponent from '../../Committees/ContactComponent';
import { flattenToAppURL } from '@plone/volto/helpers';

const CommitteeListTemplate = ({ items }) => {
 {items.map((item, index) => {
        console.log(item);
  return (
<>
...
<ContactComponent  
chair_contact_info={item?.committee_chair_vcard?.download} 
vice_chair_contact_info={item?.committee_vice_chair_vcard?.download}
   /> ;
      })}
    </>
  );
};

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

export default CommitteeListTemplate;

Works fine for single items
BTW... getting the vcard in the context of a single item (a single committee) is fine:
I retrieve the committee info in a content object and the code below is enough:

<ContactComponent  
chair_contact_info={content?.committee_chair_vcard?.download} 
vice_chair_contact_info={content?.committee_vice_chair_vcard?.download}
   /> ;

So there's something, probably obvious, that I'm missing. Maybe indexing/metadata.

It seems that I'll need to implement a custom serializer.

What I've done so far....
note: I need to support two vcards (a chair and a vice chair)

In a serializers.py file I added the following code:

from plone.restapi.serializer.converters import json_compatible
from plone.restapi.interfaces import ISerializeToJson
from zope.component import adapter
from zope.interface import implementer
from zope.interface import Interface  # Add this import
from my.addon.content.committee import ICommittee


@implementer(ISerializeToJson)
@adapter(ICommittee, Interface)
class MultiVCardSerializer:
    def __init__(self, context, request):
        self.context = context
        self.request = request

    def __call__(self, include=None, omit=None):
        data = {}

        # Define the mapping from field name to index name
        field_mapping = {
            'chair_vcard': 'committee_chair_vcard',
            'vice_chair_vcard': 'committee_vice_chair_vcard'
            # Add more mappings for other 'vcard' fields if needed
        }

        # Iterate through all fields of the context object
        for field_name, index_name in field_mapping.items():
            # Get the field value based on the field name
            field_value = getattr(self.context, field_name, None)
            if field_value is not None:
                # Serialize 'vcard' field to JSON-compatible format
                data[index_name] = json_compatible(field_value)

        return data

Then registered the new serializer using configure.zcml

...
   <adapter
      factory=".serializers.MultiVCardSerializer"
      provides="plone.restapi.interfaces.ISerializeToJson"
      for="my.addon.content.committee.ICommittee"
      />
...

After this, I do get the new serialization (the vcards in this case) when directly calling a committee endpoint. Since I was already able to do something similar before the serialization, that isn't so important. What I am still not able to do is bring forward the vcard information when the committees are returned in a listing block. The vcards are absent on each committee item in my listing block.

1 Like

Since my listing is a custom volto listing block, I'm reading up on how to bring forward the fields I need in the context of a listing block (ideally without using fullobjects).

and

and there's a whole set of standard listing blocks from the Volto source code:

hopefully, I'll find a simple example that aligns with what I'm attempting with the committees and vcards.

As a "compromise", I'm now exploring how to make use of the searchContent helper in the context of a listing variation.

In order to move forward... I've temporarily thrown in the towel :face_with_head_bandage:
I've reverted to using a custom block that returns a listing rather than using a listing variation.
There's a gap in my knowledge related to getting file fields from a custom listing variation without using full objects.

To workaround this gap, I've decided to create my own custom block.
The key feature is that the block does a fetch that includes retrieving full objects (only because I'm not getting at my file fields the way I would prefer).
That's the "icky" code:

useEffect(() => {
    console.log('Fetching committees...');
    dispatch(
      searchContent(
        '/',
        {
          portal_type: ['committee'],
          fullobjects: true,
          review_state: 'open',
        },
        term,
      ),
    );
  }, [dispatch, term]);

I'm calling it my Committees.jsx component.
Here's the full code with me calling the full objects:

import React, { useEffect, useState } from 'react';
import CommitteeChair from './CommitteeChair';
import './committee.css';
import { Input, Icon, Grid, Item, Label } from 'semantic-ui-react';
import { useDispatch, useSelector } from 'react-redux';
import { searchContent } from '@plone/volto/actions';

const Committees = (props) => {
  const dispatch = useDispatch();
  const committees = useSelector((state) => state.search.items);
  const [term, setTerm] = useState('');
  const [dropDownFilter, setDropDownFilter] = useState({ value: '' });

  useEffect(() => {
    dispatch(
      searchContent(
        '/',
        {
          portal_type: ['committee'],
          fullobjects: true,
          review_state: 'open',
        },
        term,
      ),
    );
  }, [dispatch, term]);

  useEffect(() => {
    console.log('Committees:', committees);
  }, [committees]);

  const updateSearchResults = (e) => {
    setTerm(e.target.value);
  };

  const getTags = (committees) => {
    return [
      ...new Set(
        [].concat.apply([], committees.map((item) => item.subjects)),
      ),
    ].map((itx, index) => ({ key: index, value: itx, text: itx }));
  };

  return (
    <div>
      <Input fluid icon placeholder="Search Committees">
        <Icon name="search" />
        <input
          type="text"
          className="prompt"
          value={term}
          onChange={updateSearchResults}
        />
      </Input>
      <Grid className="committee-list">
        <Grid.Row columns={2} className="committee-list-grid">
          {committees &&
            committees.map((item, index) => (
              <Grid.Column key={index}>
                <Item.Group className="committee-item" relaxed>
                  <Item className="committee-overview-wrap">
                    <Item.Content verticalAlign="middle">
                      <Item.Header as="a" href={item['@id']}>
                        {item.title}
                      </Item.Header>
                      <Item.Description>
                        <Label.Group>
                          {/*
                          {item.subjects.map((tag, index) => (
                            <Label key={index}>{tag}</Label>
                          ))}{' '}
                          */}
                        </Label.Group>
                        {item.description}
                      </Item.Description>
                    </Item.Content>
                  </Item>
                  <div>
                    <div className="committee-wrap">
                      <a
                        className="button committee-readmore-button"
                        href={item['@id']}
                      >
                        Read more
                      </a>
                    </div>
                    <div className="committee-chairs">
                      <h2 className="committee-chairs-heading">
                        Committee Leadership
                      </h2>
                      {item.committee_chair_name && (
                        <CommitteeChair
                          chairPosition="chair"
                          chairPhoto={
                            item.committee_chair_photo?.scales.thumb.download
                          }
                          contactButton={item?.committee_chair_vcard?.download}
                          chairTitle={item.committee_chair_title}
                          chairName={item.committee_chair_name}
                          chairFirm={item.committee_chair_firm}
                          chairLocation={item.committee_chair_location}
                        />
                      )}
                      {item.committee_vice_chair_name && (
                        <CommitteeChair
                          chairPosition="co-chair"
                          chairPhoto={
                            item.committee_vice_chair_photo?.scales.thumb.download
                          }
                          contactButton={
                            item?.committee_vice_chair_vcard?.download
                          }
                          chairTitle={item.committee_vice_chair_title}
                          chairName={item.committee_vice_chair_name}
                          chairFirm={item.committee_vice_chair_firm}
                          chairLocation={item.committee_vice_chair_location}
                        />
                      )}
                    </div>
                  </div>
                </Item.Group>
              </Grid.Column>
            ))}
        </Grid.Row>
      </Grid>
    </div>
  );
};

export default Committees;

I don't like this! I want to use listing variations :frowning:

Until I know how to do a similar thing in a listing variation, I'll have to settle for this approach. It feels a bit wrong (it introduces a small amount of technical debt for the project), but it allows me to move on and make progress on the rest of the project.

@pigeonflight I read quickly, but let me clarify something.

The listing block, by default, is returning you brains, because of performance.
If you need a field that is not returned in the brain (as metadata), then you have two options: either you add it as metadata in your catalog, or you ask the variation to retrieve the fullobjects.

No need for serializers or hacks. If performance is not a problem (small site) then you can do fullobjects.

I think I'm stuck on asking the variation for fullobjects.
The code I'm using instead is for a completely custom block (not a listing block variation) and in that code, I ask for fullobjects.

At risk of asking something super obvious, how would I do the same (get fullobjects) for a variation?

The listing block, by default, is returning you brains, because of performance.
If you need a field that is not returned in the brain (as metadata),

Unless you don't add any criteria in the listing block, like it is a basic folder contents view. Then you get a 'light' version of the brain with all metadata columns available in the backend portal catalog.

When I inspect the network, there's a @querystring-search with the following payload:

{
    "metadata_fields": "_all",
    "b_size": 25,
    "query": [
        {
            "i": "portal_type",
            "o": "plone.app.querystring.operation.selection.any",
            "v": [
                "committee"
            ]
        }
    ],
    "sort_order": "ascending",
    "b_start": 0
}

It does return the brains. But it does not return any of the file fields that I declared in this case committee_chair_vcard and committee_vice_chair_vcard are both missing from that output.

I'm inclined to believe that I'll need a custom indexer, to first index a text representation of my file fields. Then I can use that custom index as my metadata column.

You are mixing up indexes and metadata. A metadata column just grabs fthe defined attribute from the object to include it in the brain. You don’t need an index for that.

But you dinn’t want a full vcard structure in the metadata if it becomes too large.

It would be nice to have a restapi featurde, or maybe better server side (ddos retrieving large objects) only a utilty/adapter on CT’s and a specific search endpoint that allows you to define extra attributes that need to be pulled from the object and returned in the searchresults. Yes we wake up the object, but with batching we can do it for the current batch.

found the issue when inspecting the network.

{
  "message": "No converter for making <plone.namedfile.file.NamedBlobFile object at 0xffff689c25f0 oid 0x24ee62 in <ZODB.Connection.Connection object at 0xffff82361fd0>> (<class 'plone.namedfile.file.NamedBlobFile'>) JSON compatible.",
  "traceback": [
    "File \"/app/lib/python3.11/site-packages/ZPublisher/WSGIPublisher.py\", line 181, in transaction_pubevents",
    "    yield",
    "",
    "  File \"/app/lib/python3.11/site-packages/ZPublisher/WSGIPublisher.py\", line 391, in publish_module",
    "    response = _publish(request, new_mod_info)",
    "               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^",
    "",
    "  File \"/app/lib/python3.11/site-packages/ZPublisher/WSGIPublisher.py\", line 285, in publish",
    "    result = mapply(obj,",
    "             ^^^^^^^^^^^",
    "",
    "  File \"/app/lib/python3.11/site-packages/ZPublisher/mapply.py\", line 98, in mapply",
    "    return debug(object, args, context)",
    "           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^",
    "",
    "  File \"/app/lib/python3.11/site-packages/ZPublisher/WSGIPublisher.py\", line 68, in call_object",
    "    return obj(*args)",
    "           ^^^^^^^^^^",
    "",
    "  File \"/app/lib/python3.11/site-packages/plone/rest/service.py\", line 21, in __call__",
    "    return self.render()",
    "           ^^^^^^^^^^^^^",
    "",
    "  File \"/app/lib/python3.11/site-packages/plone/restapi/services/__init__.py\", line 19, in render",
    "    content = self.reply()",
    "              ^^^^^^^^^^^^",
    "",
    "  File \"/app/lib/python3.11/site-packages/plone/restapi/services/querystringsearch/get.py\", line 96, in reply",
    "    return querystring_search()",
    "           ^^^^^^^^^^^^^^^^^^^^",
    "",
    "  File \"/app/lib/python3.11/site-packages/plone/restapi/services/querystringsearch/get.py\", line 85, in __call__",
    "    results = getMultiAdapter((results, self.request), ISerializeToJson)(",
    "              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^",
    "",
    "  File \"/app/lib/python3.11/site-packages/plone/restapi/serializer/catalog.py\", line 61, in __call__",
    "    result = getMultiAdapter(",
    "             ^^^^^^^^^^^^^^^^",
    "",
    "  File \"/app/lib/python3.11/site-packages/plone/restapi/serializer/summary.py\", line 103, in __call__",
    "    summary[field] = json_compatible(value)",
    "                     ^^^^^^^^^^^^^^^^^^^^^^",
    "",
    "  File \"/app/lib/python3.11/site-packages/plone/restapi/serializer/converters.py\", line 68, in json_compatible",
    "    return IJsonCompatible(value, None)",
    "           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^",
    "",
    "  File \"/app/lib/python3.11/site-packages/zope/component/hooks.py\", line 144, in adapter_hook",
    "    return siteinfo.adapter_hook(interface, object, name, default)",
    "           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^",
    "",
    "  File \"/app/lib/python3.11/site-packages/plone/restapi/serializer/converters.py\", line 80, in default_converter",
    "    raise TypeError("
  ],
  "type": "TypeError"
}

Very true.. I don't need to index the full object!!!

1 Like

Does it matter that the serializers included by default in the plone.restapi package only have a serializer for NamedFileField but not NamedBlobFileField?

I tried changing the schema to use NamedFile instead of NamedBlobFile

Now the error changes from:
No converter for making <plone.namedfile.file.NamedBlobFile
to
No converter for making <plone.namedfile.file.NamedFile

{
  "message": "No converter for making <plone.namedfile.file.NamedFile object at 0xffff8c72fe70 oid 0x2d27c6 in <ZODB.Connection.Connection object at 0xffff95fcdc10>> (<class 'plone.namedfile.file.NamedFile'>) JSON compatible.",
  "traceback": [
    "File \"/app/lib/python3.11/site-packages/ZPublisher/WSGIPublisher.py\", line 181, in transaction_pubevents",
    "    yield",
    "",
    "  File \"/app/lib/python3.11/site-packages/ZPublisher/WSGIPublisher.py\", line 391, in publish_module",
    "    response = _publish(request, new_mod_info)",
    "               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^",
    "",
    "  File \"/app/lib/python3.11/site-packages/ZPublisher/WSGIPublisher.py\", line 285, in publish",
    "    result = mapply(obj,",
    "             ^^^^^^^^^^^",
    "",
    "  File \"/app/lib/python3.11/site-packages/ZPublisher/mapply.py\", line 98, in mapply",
    "    return debug(object, args, context)",
    "           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^",
    "",
    "  File \"/app/lib/python3.11/site-packages/ZPublisher/WSGIPublisher.py\", line 68, in call_object",
    "    return obj(*args)",
    "           ^^^^^^^^^^",
    "",
    "  File \"/app/lib/python3.11/site-packages/plone/rest/service.py\", line 21, in __call__",
    "    return self.render()",
    "           ^^^^^^^^^^^^^",
    "",
    "  File \"/app/lib/python3.11/site-packages/plone/restapi/services/__init__.py\", line 19, in render",
    "    content = self.reply()",
    "              ^^^^^^^^^^^^",
    "",
    "  File \"/app/lib/python3.11/site-packages/plone/restapi/services/querystringsearch/get.py\", line 96, in reply",
    "    return querystring_search()",
    "           ^^^^^^^^^^^^^^^^^^^^",
    "",
    "  File \"/app/lib/python3.11/site-packages/plone/restapi/services/querystringsearch/get.py\", line 85, in __call__",
    "    results = getMultiAdapter((results, self.request), ISerializeToJson)(",
    "              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^",
    "",
    "  File \"/app/lib/python3.11/site-packages/plone/restapi/serializer/catalog.py\", line 61, in __call__",
    "    result = getMultiAdapter(",
    "             ^^^^^^^^^^^^^^^^",
    "",
    "  File \"/app/lib/python3.11/site-packages/plone/restapi/serializer/summary.py\", line 103, in __call__",
    "    summary[field] = json_compatible(value)",
    "                     ^^^^^^^^^^^^^^^^^^^^^^",
    "",
    "  File \"/app/lib/python3.11/site-packages/plone/restapi/serializer/converters.py\", line 68, in json_compatible",
    "    return IJsonCompatible(value, None)",
    "           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^",
    "",
    "  File \"/app/lib/python3.11/site-packages/zope/component/hooks.py\", line 144, in adapter_hook",
    "    return siteinfo.adapter_hook(interface, object, name, default)",
    "           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^",
    "",
    "  File \"/app/lib/python3.11/site-packages/plone/restapi/serializer/converters.py\", line 80, in default_converter",
    "    raise TypeError("
  ],
  "type": "TypeError"
}

So, for some reason, my file attachments are not be serialized by the built-in serializers.

Following the topic what you probably want to know if it's the object has the file/files and show the link if so.

So what you really need is a metadata telling you true or false, something similar to hasPreviewImage indexer in volto but for a file:

Register the adapter and add the metadata then in your template check if the variable is true and show the download link

@csanahuja LOL!
So skip the pain and explicitly track if it hasVcard or not :slight_smile: for example.
Interesting... makes sense :thinking:
That should work!

following the example that
@csanahuja suggested
where the indexer is registered like this:

The metadata column is registered here:

(no need to register a catalog index, apparently)

So I'm attempting a similar thing for my vcards.
In an indexers.py file... I now have:

@indexer(ICommittee)
def hasChairVcard(obj, **kw):
    print("+++++++++++++++++++++++ you called me 🔥🔥 temporary debugging 🔥 ++++++++++++++++++++")
    if obj.aq_base.committee_chair_vcard:
        return True
    return False

and here's my zcml

 <adapter
      factory=".indexers.hasChairVcard"
      name="hasChairVcard"
      />

and I've added it to my metadata using profiles/default/catalog.xml

<?xml version="1.0"?>
<object name="portal_catalog">
   ...
  <column value="hasChairVcard" />
  ...
</object>

After all of that, hasChairVcard only returns null, even for scenarios where I know there's a vcard. Also the code doesn't appear to be triggered, as I'm not seeing my (very easy to spot) debugging output in the logs.

The code should be triggered on edit the object. You need to reindex the previous objects to update the metadata value.

I don't spot anything wrong, if the code is not triggered on save maybe you could try to change the indexer to the generic interface:

@indexer(Interface)

Okay, when I edit, it fires the indexer... but I needed to actually change the vcard file, if it isn't modified during the edit and save the indexer doesn't fire.
I'll best include an upgrade step with a reindex.

This is all I need! This approach will work, thank you @csanahuja!!!!!!