Update a field after Linking / Unlinking Many-Many records in SilverStripe
Asked Answered
D

3

5

I have created a Customer DataObject by extending Member. Customer has a many_many data relation with a Package DataObject.

I would like increment/decrement a Credits field in the Customer DataObject when a Package is linked / unlinked through the CMS based on the Limit field in the Package table.

Customer

class Customer extends Member {

    private static $db = array(
        'Gender' => 'Varchar(2)',
        'DateOfBirth' => 'Date',
        'Featured' => 'Boolean',
        'Credits' => 'Int'
    );

    private static $many_many = array(
        'Packages' => 'Package'
    );

    public function getCMSFields() {

        $fields = new FieldList();

        $config = GridFieldConfig_RelationEditor::create();
        $config->removeComponentsByType('GridFieldAddNewButton');

        $packageField = new GridField(
            'Packages',
            'Package',
            $this->Packages(),
            $config
        );

        $fields->addFieldToTab('Root.Package', $packageField); 

        Session::set('SingleID', $this->ID);

        $this->extend('updateCMSFields', $fields);

        return $fields;
    }
}

Package

class Package extends DataObject {

    private static $db = array(
        'Title' => 'Varchar(255)',
        'Limit' => 'Int'
    );

    private static $belongs_many_many = array(
        'Customers' => 'Customer'
    );

}
Desiderata answered 1/9, 2015 at 6:25 Comment(1)
I tried this code, please see the link, but onAfterWrite does not work.Desiderata
S
6

When you create or delete many to many relationship just one record is modified in your database - the one in table which joins elements of both sides of the relationship. Therefore neither object the relationship is based on is updated. This is why methods like: onBeforeWrite, onAfterWrite, onBeforeDelete and onAfterDelete will not be called at all and you cannot use them to detect such change.

However, Silverstripe provides class ManyManyList which is responsible for all operations connected to many to many relationships. There are two methods which are of your interest: add and remove. You can override them and put inside action to do what you need. These methods are obviously called on each link or unlink operation no matter object types are, so you should make some filtering on classes you are particularly interested in.

The proper way to override the ManyManyList class is to use Injector mechanism, so as not to modify anything inside the framework or cms folder. The example below uses relationship between Members and Groups in Silverstripe but you can easily adopt it to your need (Customer -> Member; Package -> Group).

app.yml

Injector:
    ManyManyList:
        class: ManyManyListExtended

ManyManyListExtended.php

/**
 * When adding or removing elements on a many to many relationship
 * neither side of the relationship is updated (written or deleted).
 * SilverStripe does not provide any built-in actions to get information
 * that such event occurs. This is why this class is created.
 *
 * When it is uses together with SilverStripe Injector mechanism it can provide
 * additional actions to run on many-to-many relations (see: class ManyManyList).
 */
class ManyManyListExtended extends ManyManyList {

    /**
     * Overwritten method for adding new element to many-to-many relationship.
     *
     * This is called for all many-to-many relationships combinations.
     * 'joinTable' field is used to make actions on specific relation only.
     *
     * @param mixed $item
     * @param null $extraFields
     * @throws Exception
     */
    public function add($item, $extraFields = null) {
        parent::add($item, $extraFields);

        if ($this->isGroupMembershipChange()) {
            $memberID = $this->getMembershipID($item, 'MemberID');
            $groupID = $this->getMembershipID($item, 'GroupID');
            SS_Log::log("Member ($memberID) added to Group ($groupID)", SS_Log::INFO);
            // ... put some additional actions here
        }
    }

    /**
     * Overwritten method for removing item from many-to-many relationship.
     *
     * This is called for all many-to-many relationships combinations.
     * 'joinTable' field is used to make actions on specific relation only.
     *
     * @param DataObject $item
     */
    public function remove($item) {
        parent::remove($item);

        if ($this->isGroupMembershipChange()) {
            $memberID = $this->getMembershipID($item, 'MemberID');
            $groupID = $this->getMembershipID($item, 'GroupID');
            SS_Log::log("Member ($memberID) removed from Group ($groupID)", SS_Log::INFO);
            // ... put some additional actions here            
        }
    }

    /**
     * Check if relationship is of Group-Member type.
     *
     * @return bool
     */
    private function isGroupMembershipChange() {
        return $this->getJoinTable() === 'Group_Members';
    }

    /**
     * Get the actual ID for many-to-many relationship part - local or foreign key value.
     *
     * This works both ways: make action on a Member being element of a Group OR
     * make action on a Group being part of a Member.
     *
     * @param DataObject|int $item
     * @param string $keyName
     * @return bool|null
     */
    private function getMembershipID($item, $keyName) {
        if ($this->getLocalKey() === $keyName)
            return is_object($item) ? $item->ID : $item;
        if ($this->getForeignKey() === $keyName)
            return $this->getForeignID();
        return false;
    }
}

The solution provided by 3dgoo should also work fine but IMO that code does much more "hacking" and that's why it is much less maintainable. It demands more modifications (in both classes) and needs to be multiplied if you would like to do any additional link/unlink managing, like adding custom admin module or some forms.

Selfevident answered 21/10, 2015 at 14:9 Comment(0)
A
2

The problem is when adding or removing items on a many to many relationship neither side of the relationship is written. Therefore onAfterWrite and onBeforeWrite is not called on either object.

I've come across this problem before. The solution I used isn't great but it was the only thing that worked for me.

What we can do is set an ID list of Packages to a session variable when getCMSFields is called. Then when an item is added or removed on the grid field we refresh the CMS panel to call getCMSFields again. We then retrieve the previous list and compare it to the current list. If the lists are different we can do something.

Customer

class Customer extends Member {

    // ...

    public function getCMSFields() {

        // Some JavaScript to reload the panel each time a package is added or removed
        Requirements::javascript('/mysite/javascript/cms-customer.js');

        // This is the code block that saves the package id list and checks if any changes have been made
        if ($this->ID) {
            if (Session::get($this->ID . 'CustomerPackages')) {
                $initialCustomerPackages = json_decode(Session::get($this->ID . 'CustomerPackages'), true);

                $currentCustomerPackages = $this->Packages()->getIDList();

                // Check if the package list has changed
                if($initialCustomerPackages != $currentCustomerPackages) {
                    // In here is where you put your code to do what you need
                }
            }

            Session::set($this->ID . 'CustomerPackages', json_encode($this->Packages()->getIDList()));
        }

        $fields = parent::getCMSFields();

        $config = GridFieldConfig_RelationEditor::create();
        $config->removeComponentsByType('GridFieldAddNewButton');

        $packageField = GridField::create(
            'Packages',
            'Package',
            $this->Packages(),
            $config
        );
        // This class needs to be added so our javascript gets called
        $packageField->addExtraClass('refresh-on-reload');

        $fields->addFieldToTab('Root.Package', $packageField); 

        Session::set('SingleID', $this->ID);

        $this->extend('updateCMSFields', $fields);

        return $fields;
    }
}

The if ($this->ID) { ... } code block is where all our session code happens. Also note we add a class to our grid field so our JavaScript refresh works $packageField->addExtraClass('refresh-on-reload');

As mentioned before, we need to add some JavaScript to reload the panel each time a package is added or removed from the list.

cms-customer.js

(function($) {
    $.entwine('ss', function($){
        $('.ss-gridfield.refresh-on-reload').entwine({
            reload: function(e) {
                this._super(e);
                $('.cms-content').addClass('loading');
                $('.cms-container').loadPanel(location.href, null, null, true);
            }
        });
    });
})(jQuery);

Inside the if($initialCustomerPackages != $currentCustomerPackages) { ... } code block there are a number of things you can do.

You could use $this->Packages() to fetch all the current packages associated to this customer.

You could call array_diff and array_merge to get just the packages that have been added and removed:

$changedPackageIDs = array_merge(array_diff($initialCustomerPackages, $currentCustomerPackages), array_diff($currentCustomerPackages, $initialCustomerPackages));
$changedPackages = Package::get()->byIDs($changedPackageIDs);

The above code will add this functionality to the Customer side of the relationship. If you also want to manage the many to many relationship on the Package side of the relationship you will need to add similar code to the Package getCMSFields function.

Hopefully someone can come up with a nicer solution. If not, I hope this works for you.

Arthro answered 3/9, 2015 at 0:45 Comment(1)
Just to note, this works for SS 4.1 too - the reloading bit.Lisabeth
G
0

note: Didn't actually check does the model work but by visually checking this should help you:

On the link you provided you are using

$customer = Customer::get()->Filter...

That returns a DataList of objects, not a singular object unless you specify what is the object you want from the DataList.

If you are filtering the Customers then you want to get a SPECIFIC customer from the DataList, e.g. the first one in this case.

$customer = Customer::get()->filter(array('ID' => $this->CustomerID))->first();

But You should be able to get the singular DataObject with:

$customer = $this->Customer();

As you are defining the Customer as "has_one". If the relation was a Has many, using () would get you a DataList of objects.

Protip:

You don't need to write our own debug files in SilverStripe. It has own functions for it. For example Debug::log("yay"); what writes the output to a file and Debug::dump("yay") that dumps it directly out.

Tip is that you can check what is the object that you accessing right. Debug::dump(get_class($customer)); would output only the class of the object.

Garland answered 2/9, 2015 at 7:42 Comment(5)
This has nothing to do with the question asked. This does not answer the question at all. The user has asked how to trigger a variable change each time a many to many relationship has been updated.Arthro
On the question comment there is the link " link, but onAfterWrite does not work" and If you look at it. It clearly has only one usage issue assuming that the otherwise works right. He is trying to call write to a datalist.. NOT a singluar object.Pentahedron
And with the information the asker supplied I assumed that THAT was the only issue. Not that the onAfterWrite doesn't fire.Pentahedron
Ahhh. I missed the reference to the OPs comment link. Looking at their link I see your answer relates to what they have posted in there. I see the relevance of your post now. Sorry for my previous comment. I'll remove it.Arthro
Don't remove it. Some else might make the assumption that I didn't try to answer the question :D Also I should have tested the model but saddly didn't have the time so I ended up answering only the obvious flaw. Eg there is, as you pointed out, the possibility for the onAfterWrite not to execute at all.Pentahedron

© 2022 - 2024 — McMap. All rights reserved.