Silverstripe. Modifying gridfieldconfig in modeladmin to add sortable headers - silverstripe

I'm using modeladmin to display a number of event DataObjects.
I've added a number of columns to the summary fields which the client wishes to be able to sort by. Currently, only title is sortable by default. Is it possible to modify gridfieldconfig in modeladmin? In particular to add fields to GridFieldSortableHeader?
Here is my Event dataobject with the summary fields that I need to be able to sort by in modeladmin:
......
static $summary_fields = array('Title', 'DescriptionSummary', 'EventStartDate', 'EventEndDate', 'EventVenue');
static $field_labels = array('DescriptionSummary' => 'Description', 'EventStartDate' => 'Start Date', 'EventEndDate' => 'End Date', 'EventVenue' => 'Venue');
private $widget;
//TO GET THE SUMMARY FIELD VALUES
public function getEventVenue(){
if ($eventVenue = $this->Venue()->Title) return $eventVenue;
return "No Venue specified";
}
public function getEventStartDate(){
if ($startDate = DataObject::get_one('CalendarDateTime', 'EventID = '.$this->ID)) return $startDate->StartDate;
return "No start dates specified";
}
public function getEventEndDate(){
if ($startDate = DataObject::get_one('CalendarDateTime', 'EventID = '.$this->ID)) return $startDate->EndDate;
return "No end dates specified";
}
....
and my event admin:
class EventAdmin extends ModelAdmin {
public static $managed_models = array('CalendarEvent', 'Venue', 'EventCategory');
static $url_segment = 'events';
static $menu_title = 'Events';
}

I've just added some info to doc.silverstripe.org on how to override the edit form and access the GridField within (link). The relevant bits (adapted to your use case):
class EventAdmin extends ModelAdmin {
// ...
public function getEditForm($id = null, $fields = null) {
$form = parent::getEditForm($id, $fields);
$gridField = $form->Fields()->fieldByName($this->sanitiseClassName($this->modelClass));
$gridField->getConfig()->getComponentByType('GridFieldSortableHeader')
->setFieldSorting(array(...));
return $form;
}
}
In case you're trying to sort by the CalendarDate relationship and the EventStartDate field, you'll generally have to override the results list in ModelAdmin, see docs.
While you can add the necessary join there (DataQuery->leftJoin), its not possible
to select additional columns in the query. So that would just allow you to sort by
EventStartDate by default, but not to re-sort the GridField via the UI.
Its a missing feature, we should really support "dot notations" in DataList->sort() out of the box.

Related

How to configure the Symfony serializer to link relational fields when deserialising?

Objective:
I'm importing a bunch of JSON files data into the database. Keeping the id fields the same as in the json files and link the relational id's to existing rows.
Problem:
When deserialising relational fields, the serialiser is inserting new empty records rather than linking them to existing rows.
Context:
I'm deserialising the files into respective entity objects.
Let's focus on one called Region.json which has an entity called Region and has a ManyToOne relation to Country.
Here is a snippet from Region.json the fields are the same as the entity properties.
[
{
"id": 1,
"name": "Aera",
"code": AR",
"country": 1, // relational field
"isActive": true,
},
{
"id": 2,
"name": "Mauw",
"code": "MW",
"country": 8, // relational field
"isActive": true,
}
]
The deserialisation process is as follows:
public function getDeserializeData(): mixed
{
$classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader()));
$normalizers = [new ObjectNormalizer( classMetadataFactory: $classMetadataFactory,propertyTypeExtractor: new ReflectionExtractor()), new GetSetMethodNormalizer(), new ArrayDenormalizer()];
$encoders = [new JsonEncoder(), new XmlEncoder(), new CsvEncoder()];
$serializer = new Serializer(
normalizers: $normalizers,
encoders: $encoders
);
return $serializer->deserialize(
$this->staticDataFile->getContents(),
$this->getEntityNamespace() . '[]',
$this->staticDataFile->getExtension()
);
}
I'm using the ReflectionExtractor because are you can see the json data files have pre-defined ids and this can not be changed.
If I try to change the generated value strategy from 'IDENTITY' to 'NONE' I get the following error:
Entity of type App\Entity\Country is missing an assigned ID for field 'id'. The identifier generation strategy for this
entity requires the ID field to be populated before EntityManager#persist() is called. If you want automatically genera
ted identifiers instead you need to adjust the metadata mapping accordingly.
You will likely need a custom (De-)Normalizer for this, designed for each specific entity, e.g. for Region. Then you know, which fields contain associated data like country and how to search for that data. Your normalizer will take the id from the input, get the country from the database and add it in place of the number. It could look roughly like this:
class RegionDenormalizer implements DenormalizerInterface
{
public function __construct(
private CountryRepository $countryRepository,
) {}
public functionsupportsDenormalization(mixed $data, string $type, string $format = null /* , array $context = [] */)
{
return $type === Region::class;
}
public function denormalize(mixed $data, string $type, string $format = null, array $context = [])
{
$country = $this->countryRepository->find($data[’country’];
if (!$country instanceof Country)
{
// throw an Exception probably
}
$region = $context[AbstractNormalizer::OBJECT_TO_POPULATE];
$region->setCountry($country);
// Probably also set the other fields
}
}
You can also use $context to prevent your Denormalizer from being called twice, replace the id with the country in data and then use the original ObjectNormalizer. This is a bit more complicated, but I prefer this:
class RegionDenormalizer implements DenormalizerInterface, DenormalizerAwareInterface
{
use DenormalizerAwareTrait;
public function __construct(
private CountryRepository $countryRepository,
) {}
public functionsupportsDenormalization(mixed $data, string $type, string $format = null /* , array $context = [] */)
{
return $type === Region::class
&& !in_array($data[‘id’], $context[‘visited_regions’] ?? []);
}
public function denormalize(mixed $data, string $type, string $format = null, array $context = [])
{
$innerContext = $context;
$innerContext[‘visited_regions’][] = $data[‘id’];
$country = $this->countryRepository->find($data[’country’];
if (!$country instanceof Country)
{
// throw an Exception probably
}
$innerContext = $context;
// By setting this inner context, we prevent this listener from being called again for this region
$innerContext[‘visited_regions’][] = $data[‘id’];
// By replacing the country in data, we now have the expected country instead of the id or a new entity
$data[‘country’] = $country;
return $this->denormalizer->denormalize($data, $type, $innerContext);
}
}
I prefer this, because I don’t have to care about how to deserialize the region itself, only about replacing the country-id with the actual instance, but handling the context is more difficult.
Note: the single quotes in the code samples are wrong, because I am typing this on an iPad. You will have to replace them.

How to sort images as part of a many_many in silverstripe?

I have created a $many_many array for $slideImages on my home page. After much reading and trying I am still unable to tell the CMS the order I want the images to appear in the template. By default they are sorted by upload date I believe.
I can create the gridfield but I can't seem to get a textfield to enter in a sorting number. Right now I just finished a rabbit trail that led me to gridFieldComponent but I get an error and the docs are not helping me.
use SilverStripe\Assets\Image;
use SilverStripe\AssetAdmin\Forms\UploadField;
use SilverStripe\Forms\TextField;
use SilverStripe\Forms\GridField\GridFieldComponent;
use SilverStripe\Forms\GridField\GridFieldConfig_RelationEditor;
use SilverStripe\Forms\GridField\GridField;
class HomePage extends Page
{
private static $db = [];
private static $has_one = [];
private static $many_many = [
'SliderImage'=>Image::Class
];
private static $owns = [
'SliderImage'
];
private static $many_many_extraFields= [
'SliderImage'=>array(
'Sort'=>'Int'
)
];
public function getCMSFields(){
$fields = parent::getCMSFields();
$fields->addFieldToTab('Root.Attachments', UploadField::create('SliderImage', 'Sllider Images')->setFolderName('HomePageSlides'));
$gridFieldConfig = GridFieldConfig_RelationEditor::create()->addComponents(
new GridFieldComponent(TextField('Sort'))
);
$gridField = new GridField("SliderImage", "Slider Image", $this->SliderImage()->sort('Sort'), $gridFieldConfig);
$fields->addFieldToTab("Root.Attachments", $gridField);
return $fields;
}
}
The Error I get is:
"Uncaught Error: Cannot instantiate interface
SilverStripe\Forms\GridField\GridFieldComponent"
As per wmk's comment, your are missing a new or ::create on the TextField initialization for your Sort field.
Should be:
new GridFieldComponent(new TextField('Sort'))
Or better yet:
GridFieldComponent::create(TextField::create('Sort'))

Create a DataObject to build table rows and columns in SilverStripe

I'm trying to do something very different for a SilverStripe site: on several subpages are tables of data, and these tables each have their own set of column headers, and some tables have more columns than others. I want to avoid building out tables in the Rich Text Editor as that is prone to a lot of mistakes and it's a hassle to maintain over time.
What I would like to do is create a DataObject that allows for a nth number of columns and an nth number of corresponding rows. This way I can call a loop (or possibly two) inside the template where I have the HTML table structure already in place. The content managers have full control over which columns are in the tables for any give subpage, and they don't have to worry about maintaining the HTML table setup.
I've had a couple of ideas that don't produce the results I want without a) making the UI experience too complex for content managers and b) not being able to properly link the columns with the rows.
I have thought of creating a DataObject for Table Headers and one for Table Rows, but then I'm stumped on how to combine them in such a way that would make sense, especially since there could be any number of columns.
Would anyone have any suggestions on to approach this?
UPDATE: Ok, I have something going for the TableRowItem data object that may work, and is close to working. However, the issue is this now: How do I save the field values to the database when I am creating them basically on the fly? As it is now, the only field that saves to the database is the PDF file upload field, everything else is erased upon hitting "create."
<?php
class TruckBodyPdfTableRowItem extends DataObject {
private static $db = array(
);
// One-to-one relationship with gallery page
private static $has_one = array(
'TablePage'=> 'Page',
'TableColumnSet' => 'TableColumnSet',
'PDF' => 'File',
);
// tidy up the CMS by not showing these fields
public function getCMSFields() {
$fields = parent::getCMSFields();
$fields->removeFieldFromTab("Root.Main","TablePageID");
$fields->removeFieldFromTab("Root.Main","TableColumnSetID");
$fields->removeFieldFromTab("Root.Main","SortOrder");
$fields->addFieldsToTab("Root.Main", $this->getMyColumnOptions());
return $fields;
}
public function getMyColumnOptions()
{
$columnArray = [];
$Columns = DataObject::get('TableColumnSet');
foreach($Columns as $Column){
$columnArray[] = TextField::create($Column->TableColumnHeader);
}
return $columnArray;
}
// Tell the datagrid what fields to show in the table
private static $summary_fields = array(
);
public function canEdit() {
return true;
}
public function canDelete() {
return true;
}
public function canCreate(){
return true;
}
public function canPublish(){
return true;
}
public function canView(){
return true;
}
}
But those are the tricky parts: Figuring out how to map values from one DataObject into labels for another, and then auto-generating an nth number of rows based on how many columns have been created.
<?php
class TablePage extends Page
{
private static $db = array(
'H1' => 'varchar(250)',
);
private static $has_many = array(
'TableRowItems' => 'TableRowItem',
'TableColumnSets' => 'TableColumnSet'
);
private static $has_one = array(
);
public function getCMSFields()
{
$fields = parent::getCMSFields();
$fields->addFieldToTab("Root.Main", new TextField("H1"), "Content");
$gridFieldConfig = GridFieldConfig_RecordEditor::create();
$gridFieldConfig->getComponentByType('GridFieldDataColumns')->setDisplayFields(array(
// field from drawer class => label in UI
'TableColumnHeader' => 'Table Column Header'
));
$gridfield = new GridField(
"TableColumnSets",
"Table Column Sets",
$this->TableColumnSets(),
$gridFieldConfig
);
$fields->addFieldToTab('Root.Specs Table', $gridfield);
$gridFieldConfig2 = GridFieldConfig_RecordEditor::create();
$gridFieldConfig2->getComponentByType('GridFieldDataColumns')->setDisplayFields(array(
// field from drawer class => label in UI
'TableRowValue' => 'Table Row Value'
));
$gridfield2 = new GridField(
"TableRowItems",
"Table Row Items",
$this->TableRowItems(),
$gridFieldConfig2
);
$fields->addFieldToTab('Root.Specs Table', $gridfield2);
return $fields;
}
}
class TablePage_Controller extends Page_Controller
{
private static $allowed_actions = array(
);
public function init()
{
parent::init();
// You can include any CSS or JS required by your project here.
// See: http://doc.silverstripe.org/framework/en/reference/requirements
}
}
Here are the classes TableColumnSet and TableRowValue. I figured, there would be one set of column headers associated with an nth number of rows, so I figured there would be a $has_many relationship between the two classes, in that a TableColumnSet could have many TableRowValues, but there would only be one TableColumnSet for all the TableRowValues. I was hoping to associate the TableRowValues to the TableColumnSet values using a dropdown with all the column headers created but that just sounds like a bad idea. Having to manually associate every field in a row to the column headers seems tedious and potentially difficult content managers.
<?php
class TableColumnSet extends DataObject {
private static $db = array(
'SortOrder' => 'Int',
'TableColumnHeader'=>'varchar(250)'
);
// One-to-one relationship with gallery page
private static $has_one = array(
'TablePage'=> 'Page'
);
private static $has_many = array(
'TableRowItems' => 'TableRowItem'
);
// tidy up the CMS by not showing these fields
public function getCMSFields() {
$fields = parent::getCMSFields();
$fields->removeFieldFromTab("Root.Main","TablePageID");
$fields->removeFieldFromTab("Root.Main","SortOrder");
return $fields;
}
// Tell the datagrid what fields to show in the table
private static $summary_fields = array(
'TableColumnHeader' => 'Table Column Header',
);
public function canEdit() {
return true;
}
public function canDelete() {
return true;
}
public function canCreate(){
return true;
}
public function canPublish(){
return true;
}
public function canView(){
return true;
}
}
I feel like may be on to something here, at least in regards to the relationship between the column headers and rows? I'm not sure, though.
I might be off base here, since I have no experience with SilverStripe. But... my PHP / HTML table solution might apply here:
<?php
// parse your table data into this structure
$tableData = array(
"rowOne" => array(
"columnName" => "columnValue1",
"colName" => "colValue1"
// .....
),
"rowTwo" => array(
"columnName" => "columnValue2",
"colName" => "colValue2"
// .....
)
);
// now loop through the array with a printHeader parameter
$tableHTML = array(
"<table>"
);
$tableHead = array(
"<thead>"
);
$tableBody = array(
"<tbody>"
);
$printHeader = true;
foreach ($tableData as $row) {
foreach ($row as $column => $value) {
$tableRow = "<tr>";
if ($printHeader) {
$tableHead[] = "<th>".$column."</th>";
}
$tableRow .= "<td>".$value."</td>";
}
$tableBody[] = $tableRow."</tr>";
// after the first row, set printHeader to false and close the <thead>
$printHeader = false;
$tableHead[] = "</thead>";
}
// implode table header to string with linebreaks
$tableHead = implode(PHP_EOL, $tableHead);
// close table <tbody> & implode to string with linebreaks
$tableBody[] = "</tbody>";
$tableBody = implode(PHP_EOL, $tableBody);
// add all table elements together
$tableHTML[] = $tableHead;
$tableHTML[] = $tableBody;
$tableHTML[] = "</table>";
// implode table array to string
$tableHTML = implode(PHP_EOL, $tableHTML);
// print or write anywhere
echo($tableHTML);
?>
The array structure for all the steps in the loop are to keep the default server memory cleaner to remove old data. If you concat ($var .= "string";) everything as strings all the references will stay stored in memory and bog down the server when displaying large tables.
I hope this is of some help

Sorting list of VirtualPages on a field from its Page

I have an AreaPage with $many_many VirtualPages:
class AreaPage extends Page {
/**
* #var array
*/
private static $many_many = [
'RelatedVirtualPages' => 'VirtualPage'
];
// ...
}
The RelatedVirtualPages are copying content from ContentPages:
class ContentPage extends Page {
/**
* #var array
*/
private static $db = [
'Highlighted' => 'Boolean'
];
// ...
}
What's the best way to sort RelatedVirtualPages on the Highlighted db field of the ContentPage that it's copying?
Virtual Pages could be pointed at pages of different types and there is no enforcement that all of those pages are ContentPages, or at least pages that have a Hightlighted db field. You can ensure this manually when you create your SiteTree, but users could come along and screw it up so keep this in mind.
Here is some psuedo-code that might help you get started. It assumes that all virtual pages are ContentPages. If you will have multiple types of VirtualPages referenced by an AreaPage then this is probably not sufficient.
$virtualPages = $myAreaPage->RelatedVirtualPages();
$contentSourcePages = ContentPage::get()->byIDs($virtualPage->column('CopyContentFromID'));
$sortedSourcePages = $contentSourcePages->sort('Highlighted','ASC');
You possibly could also use an innerJoin, but then you have to deal with _Live tables and possibly multiple page tables (again if not just using ContentPage as VirtualPage) which could lead to some complicated scenarios.
Update
So, to summarize in my own words, you need a list of the VirtualContentPages linked to a specific AreaPage sorted on the Highlighted field from the ContentPage that each VirtualContentPage links to. If this summary is accurate, would this work:
$sortedVirtualPages = $myAreaPage->RelatedVirtualPages()
->innerJoin('ContentPage', '"ContentPage"."ID" = "VirtualContentPage"."CopyContentFromID"')
->sort('Highlighted DESC');
I could not find a very clean method, but did find two ways to achieve this. The function goes in the class AreaPage
First
public function getRelatedVirtualPages()
{
$items = $this->getManyManyComponents('RelatedVirtualPages');
$highlighted = $items->filterByCallback(function($record, $list) {
if($record->CopyContentFrom() instanceOf ContentPage) {
//return ! $record->CopyContentFrom()->Highlighted; // ASC
return $record->CopyContentFrom()->Highlighted; // DESC
}
});
$highlighted->merge($items);
$highlighted->removeDuplicates();
return $highlighted;
}
Second (the method you described in the comments)
public function getRelatedVirtualPages()
{
$items = $this->getManyManyComponents('RelatedVirtualPages');
$arrayList = new ArrayList();
foreach($items as $virtualPage)
{
if($virtualPage->CopyContentFrom() instanceOf ContentPage) {
$virtualPage->Highlighted = $virtualPage->CopyContentFrom()->Highlighted;
$arrayList->push($virtualPage);
}
}
$arrayList = $arrayList->sort('Highlighted DESC');
return $arrayList;
}
I'm not very proud of any of these solutions, but I believe they do fit your criteria.
Here's what I ended up doing, which I think works:
/**
* #return ArrayList
*/
public function VirtualPages()
{
$result = [];
$virtualPages = $this->RelatedVirtualPages();
$contentPages = ContentPage::get()
->byIDs($virtualPages->column('CopyContentFromID'))
->map('ID', 'Highlighted')
->toArray();
foreach($virtualPages as $virtualPage) {
$highlighted = $contentPages[$virtualPage->CopyContentFromID];
$virtualPage->Highlighted = $highlighted;
$result[] = $virtualPage;
}
return ArrayList::create(
$result
);
}
And then it's sortable like so:
$areaPage->VirtualPages()->sort('Highlighted DESC');
Thank you for all the answers and pointers. I'll wait a bit before marking any answer.
Couldn't you just do
//just get one areapage
$AreaPageItem = AreaPage::get()->First();
//now get the RelatedVirtualPages sorted
$related_pages = $AreaPageItem->RelatedVirtualPages()->sort("Highlighted","ASC");

Symfony 2 Sonata admin list views do not display subclasses

Currently I cant get subclasses to appear in a list view using sonta admin bundle for symfony 2
I can get it working for create forms as per the advanced config page (http://sonata-project.org/bundles/admin/2-1/doc/reference/advance.html) but how can you do this with the list view?
If i pass the subclass in the url - list?subclass=MySubClassName
and set the object in my listAction
$object = $this->admin->getNewInstance();
$this->admin->setSubject($object);
I can get the subject and configure the correct fields with configureListFields()
if ($subject instanceof MySubClassName) {
$listMapper->add('MySubClassNameID');
$listMapper->add('MySubClassNameKey');
$listMapper->add('MySubClassNameStatus','text');
}
but the end results table is always blank and the symfony debug toolbar seems to show that the db queries are looking for the parent class. Anyone got this to work?
I'm not sure what you mean with those "subclasses" in the list view, but if you want to add a field form another entity (connected through a foreign key with yours) you can do it lie this:
$listMapper
->addIdentifier('id')
->addIdentifier('title')
->add('name')
->add('entity1.customField1')
->add('entity2.customField2');
Incase anyone else faces this I found out how to do this.
To make it work in a way similar to the edit page you would pass the subclass in the url
...list?subclass=MySubClass
set the subject of your listAction in your custom admin crud controller
public function listAction()
{
if (false === $this->admin->isGranted('LIST')) {
throw new AccessDeniedException();
}
if ($listMode = $this->getRequest()->get('_list_mode')) {
$this->admin->setListMode($listMode);
}
$this->admin->setSubject($this->admin->getNewInstance());
$datagrid = $this->admin->getDatagrid();
$formView = $datagrid->getForm()->createView();
// set the theme for the current Admin Form
$this->get('twig')->getExtension('form')->renderer->setTheme($formView, $this->admin->getFilterTheme());
return $this->render($this->admin->getTemplate('list'), array(
'action' => 'list',
'form' => $formView,
'datagrid' => $datagrid,
'csrf_token' => $this->getCsrfToken('sonata.batch'),
));
}
and then over-ride the createQuery method in your Admin class
public function createQuery($context = 'list')
{
$cName = get_class($this->getSubject());
$query = $this->getModelManager()->createQuery($cName);
foreach ($this->extensions as $extension) {
$extension->configureQuery($this, $query, $context);
}
return $query;
}
If you pass anything with url parameters you should also override getPersistentParameters to add your url request to Pager, FilterForm and the form for batchActions (or others that appear on the list view)
<?php
class YourAdmin extends Admin
{
public function getPersistentParameters()
{
if (!$this->getRequest()) {
return array();
}
return array(
'subclass' => $this->getRequest()->get('subclass'),
);
}
}

Resources