HackerOne users: Testing against this community violates our program's Terms of Service and will result in your bounty being denied.
Please upgrade here. These earlier versions are no longer being updated and have security issues.

Adding a "skip to answer" button to a question

This discussion is related to the Q&A addon.

First some context:
I commented out the part of the QnA plugin that takes the answers from the db, pins them, and then removes them from the post count. So the answers are still in their original position but simply marked as "answer".

For the time being my objective was to add a "skip to answer" button in the post option of questions that have answers.(ideally before the "favorite" star)
That button will skip to the first answer for the question at hand.

This is what i got:

public function DiscussionController_BeforeDiscussionOptions_Handler($Sender) {
         if (strcasecmp($Sender->Data('Discussion.QnA'), 'Accepted') != 0)
         return;

         $Answer = Gdn::SQL()->GetWhere('Comment', array('DiscussionID' => $Sender->Data('Discussion.DiscussionID'), 'Qna' => 'Accepted'))->FirstRow(DATASET_TYPE_ARRAY);
         $AnswerID = $Answer['CommentID'];
         $DiscussionID = $Sender->Data('Discussion.DiscussionID');
         $DiscussionName = $Sender->Data('Discussion.Name');
         $DiscussionName = preg_replace('/\s+/', '-', $DiscussionName); //Not necessary but keeps in line with default formatting
         echo '<span><a href="'.Gdn_Format::text($DiscussionName).'#Comment_'.Gdn_Format::text($AnswerID).'">Skip to Answer</a></span>';
     }

It works and does what i need. However, I got this from scraping code parts of plugins.
So there are things i don't understand completely.
Like what does this do, exactly: "->FirstRow(DATASET_TYPE_ARRAY)".
My take from this is that it gets the first row that it finds with the given search and adds it to the var as an array type.
Are there other possible returns?

Also, I did things in what seemed like the most logical choice for a first try, but is there a better way?
Maybe a more "vanilla way" of doing things?

I'm also not sure if there is a explicit need for Gdn_Format::text in this case, but I assume it's safer.

I defer to the expert knowledge of the vanilla-php masters here to help me out.
I'm a lot better than when I started, but I'm still learning a lot about vanilla everyday.
Feel free to correct me in any way, as stated I'm still learning.

Thanks in advance.

Comments

  • R_JR_J Ex-Fanboy Munich Moderator

    @Kiori said:
    Like what does this do, exactly: "->FirstRow(DATASET_TYPE_ARRAY)".
    My take from this is that it gets the first row that it finds with the given search and adds it to the var as an array type.
    Are there other possible returns?

    Let's take a look at that code and I'll rewrite it a little bit in order to be able to add comments:

    $Answer = Gdn::SQL() // You can get more information on everything connected to the sql class by looking at the file /library/database/class.sqldriver.php
        ->GetWhere( // Look for that method in the file mentioned above. Really, do it! You will see that you find valuable information in Vanillas source code. Don't skip that...
            'Comment', // The table to get rows from. Boring, if you've read the documentation ;-)
            array(
                'DiscussionID' => $Sender->Data('Discussion.DiscussionID'),
                'Qna' => 'Accepted'
            ) // Boring as well. This is only the select criteria.
        )->FirstRow(DATASET_TYPE_ARRAY); // Darn! class.sqldriver.php does not have a method firstRow, other wise you would have known what to do!
    

    The result of an sql query is an object, defined by the class DataSet. And when you look at class.dataset.php, you'll find the method firstRow(), which ends with return $Result[0];. If you know a little bit of PHP, you know that return $Result[0]; means that the first element in that array (i.e. the first row in the result of an sql query) is returned. It will always be the first row - hence the name ;)


  • R_JR_J Ex-Fanboy Munich Moderator

    Let's take a look at your code:

    public function DiscussionController_BeforeDiscussionOptions_Handler($Sender) {
    You've used BeforeDiscussionOptions, but take a look where that event is fired (/applications/vanilla/views/discussion/index.php):

    $this->fireEvent('BeforeDiscussionOptions');
    WriteBookmarkLink();
    WriteDiscussionOptions();
    

    Since Vanilla uses very descriptive function names, you can see that you have placed your link only in the near of the options, but the bookmark link comes first. Was that what you wanted or did you want to make this one of the options?


    if (strcasecmp($Sender->Data('Discussion.QnA'), 'Accepted') != 0)
        return;
    

    Please don't leaveout the curly braces.
    This should check if the current discussion has "Accepted" in the column "QnA", but I must admit I do not really understand that comparison. The discussion is passed to your function as a "EventArgument" - that's how Vanilla calls it. So you should access that column as $Sender->EventArguments['Discussion']->SomeColumnName. You have chosen to do a case insensitive comparison, but there shouldn't be a lower case "accepted" in that column ever, so I would write that part like this (including comment):

    // Stop if this discussion has no accepted(!) answers.
    if ($Sender->EventArguments['Discussion']->QnA != 'Accepted') {
        return;
    }
    

    $Answer = Gdn::SQL()->GetWhere('Comment', array('DiscussionID' => $Sender->Data('Discussion.DiscussionID'), 'Qna' => 'Accepted'))->FirstRow(DATASET_TYPE_ARRAY);
    

    You have alogical error here: there can be more than one accepted answer and you will always only get the first one. I think this might be a show stopper for your approach, but you have to think on that by yourself.
    But what you do here shouldn't be needed. Do you remember my first answer? I've solved the dsiplay "problem" by setting $Sender->data('Answers') to null. So obviously all Answers are already available. You do a database lookup. Avoid that whenever that is possible, since it slows down your side.

    You could theoretically replace that by

    $Answers = $Sender->data('Answers');
    $Answer = $Answers[0];
    

    The only problem is that in my post above, I have told you to delete that value and so it wouldn't be available any more, since "AfterPageTitle" (the event we used to unset the value) is called before this event.
    So forget the complete method where the value is unset and we do this here in this method. It is early enough.
    Add $Sender->setData('Answers', null); to this method, since a) we already stored the Answers in the local var $Answers and b) do not want to see them later on right below the discussion.

    Sorry for that extra complexity...


     $AnswerID = $Answer['CommentID'];
     $DiscussionID = $Sender->Data('Discussion.DiscussionID');
     $DiscussionName = $Sender->Data('Discussion.Name');
     $DiscussionName = preg_replace('/\s+/', '-', $DiscussionName); //Not necessary but keeps in line with default formatting
     echo '<span><a href="'.Gdn_Format::text($DiscussionName).'#Comment_'.Gdn_Format::text($AnswerID).'">Skip to Answer</a></span>';
    

    Basically you want to write `Skip to Answer.

    I'm lazy. Most of the time you can take a look at how others solved your problem. There are many links to comments and you could simply take a look at how the has been made. On a normal "/discussion/123/blablabla" page, every comment has a link if you look at the html, they all have the css class "Permalink". Do a full text search in /applications/vanilla/views/discussion/ for "Permalink" and you find a dozen results. One looks very promising:
    $Permalink = val('Url', $Comment, '/discussion/comment/'.$Comment->CommentID.'/#Comment_'.$Comment->CommentID);
    Although you might not be able to understand everything, it should be obvious that this builds something with "/discussion/comment", "CommentID" and "#Comment". So simply copy this!

    $AnswerLink = val( // Vanillas helper function which gets a the value for a given key from an array or object or returns a default value if the array/object does not have such a key
        // First comes the key to look for
        'Url',
        // This is the array/object which should contain the key. You have extracted one answer above and answers are comments. So we can replace every occurance of $Comment in this context with $Answer. Very comfortable.
        $Answer,
        // Now we provide a default value just in case there is no url given in the comment.
        '/discussion/comment/'.$Answer->CommentID.'/#Comment_'.$Answer->CommentID
    );
    

    Although this would be working, sometimes it doesn't hurt to think twice. If there is an url provided, why give an alternative. Maybe the column url is always filled. Take a look at the table Comment in the database...
    Surprise, surprise: there is no such column! So no need for "take a look at comment for url, but use thisandthat and if you cannot find the url".
    Let's cut it down to:

    echo '<span>', anchor(
        t('Skip to Answer'),
        '/discussion/comment/'.$Answer->CommentID.'/#Comment_'.$Answer->CommentID,
        '',
        array('rel' => 'nofollow')
        ), '</span>';
    

    There are some "new" things in here. Instead of using html and the anchor tag, I used Vanillas anchor() helper function. It will ensure that your link would look right even on forums which are in a subfolder like that: www.example.com/forum/discussion/123/blablabla. It's handy. Look it up in /library/core/functions.render.php
    I wrote t('Skip to Answer') and not only the string. Everything you enclose in t() can be translated. Just get used to do it that way so that your plugins will be usable even for non-english speakers.
    I've taken a look how the $Permalink var is processed further on below where I found the code snippet and it got that rel=nofollow atribute. So I simply copied that without further thinking. I'm lazy.


    In one piece:

    public function DiscussionController_BeforeDiscussionOptions_Handler($sender) {
        // Stop if this discussion has no accepted(!) answers.
        if ($sender->EventArguments['Discussion']->QnA != 'Accepted') {
            return;
        }
    
        // Get all answers.
        $answers = $sender->data('Answers');
        // Pick only the first one.
        $answer = $answers[0];
    
        // Insert link to first answer.
        echo '<span>',
            anchor(
                t('Skip to Answer'),
                '/discussion/comment/'.$Answer->CommentID.'/#Comment_'.$Answer->CommentID,
                '',
                array('rel' => 'nofollow')
            ),
            '</span>';
    
        // Delete Answers so that they will not be displayed below the discussion.
        $sender->setData('Answers', null);
    }
    


    rbrahmsonKiori
  • @R_J
    Thanks for a such a detailed response, learned a lot from it.
    I agree that extending is always better than hacking, but when you don't know where to start you go with what you can do. :smiley:

    There were a few typos in your final code, namely $Answer, instead of $answer.
    Also, in my tests this didn't work:

    if ($sender->EventArguments['Discussion']->QnA != 'Accepted') {
                    return;
    }
    

    The other solution i used, with $sender->data, that i copied from the QnA plugin, worked.

    I have a few other queries if you got the time:

    This code, from my example above works when its on the QnA plugin, but doesn't outside of it, don't know why:

    $Answer = Gdn::SQL()->GetWhere('Comment', array('DiscussionID' => $Sender->Data('Discussion.DiscussionID'), 'Qna' => 'Accepted'))->FirstRow(DATASET_TYPE_ARRAY);
    

    Could you explain a bit more of how anchor() works, is the ,'', after the href really needed?
    I took it out and added a class before rel in the next array, nothing went wrong.

    Some plugins use $sender->Data, others $sender->EventArguments, other use a $args in the function, along with $args['something']->more.
    I get confused by all of that.
    From your code and deduction it seems to me that "Data" is part of the runtime object, hence why you set it in one script(QnA) and called it on another(this script), not sure what the difference between the other 2 is, or how, when to use them.

    Thanks to you I learned something awesome yesterday.
    BeforeDiscussionOptions works, but i figured: what if i wanted AfterDiscussionOptions?
    I learned that if i edit /applications/vanilla/views/discussion/index.php and add this:
    $this->fireEvent('AfterDiscussionOptions'); after WriteAdminCheck(); it just works. Awesome!
    Is there a way to do this as an extension, vs a 'hack' as you called it?

    In that same vein, of avoiding editing the original files, what if I want to edit something that is a text, like the 'Most recent by' in the post listing, and make it a link to discussion#latest?
    I don't really like how the posts always go to the latest discussion, but I don' want that option to go away.
    It's not obvious to me if this type of "extension" is even possible, but it may come in handy.
    I know the original text can be hacked of course.

    Finally, The 'FirstRow()' worked because I thought only one answer would be fine. Now I'm thinking it should cycle between the answers if there is a 'next' one.
    Can you give any suggestion on how to do that?
    My way would be to relay the commentids to javascript and let it check on the address if it is at comment id, if so switch the link with the second id and cycle through. But maybe you have a better way, in php fashion that will integrate well with the plugin.

    Thanks again for all the great responses, the button is almost done and i learned a few things.

  • R_JR_J Ex-Fanboy Munich Moderator

    Well, I wrote that code without testing. Another thing is that I try to use new coding conventions whenever possible and the result is inconsistent code. But take it as a challenge to review my code.

    I will not answer your questions in the order you've asked them, but I'll try to answer all of them.

    anchor() first and EventArguments, args, data next, since those two questions/answers are basics for getting a better understanding of Vanilla.

    Whenever you are not sure about what a Vanilla function does, try to find where that function is defined in the source. One of the most useful tools for writing plugins for Vanilla is an editor with full text search capabilities. Search for "function anchor(" across Vanilla to find useful information:

    In /library/core/functions.render.php you'll find the anchor function:

    /**
     * Builds and returns an anchor tag.
     */
    function anchor($Text, $Destination = '', $CssClass = '', $Attributes = array(), $ForceAnchor = false) {
    

    And that might already be the answer to your question

    @Kiori said:
    Could you explain a bit more of how anchor() works, is the ,'', after the href really needed?

    anchor accepts five arguments. I "skipped" the $CssClass argument since I didn't want to give that link a special css class, but I needed to add $Attributes. I think it becomes obvious to you if you think longer about that: if you want to "use" an argument in a function, all previous arguments must be provided before. Let me put somewhat naive: there is no way for PHP to tell if an argument should be the $Text, the $Destination, the $CssClass etc, than the order in which you provide the values.

    The way the anchor function handles its arguments makes it somewhat hard to experiment by simply leaving out the class argument. Just by looking at the code I would think that any array provided as the third argument would be treated the same as $Arguments, which is merely a coincidence. It is better to use a functions the way it is documented and not the way it could be "misused". Since anchor is a bad example for arguments, please pick another function if you still have questions on their arguments :-/

    Next answer will take some time...


    Kiorirbrahmson
  • R_JR_J Ex-Fanboy Munich Moderator

    @Kiori said:
    Some plugins use $sender->Data, others $sender->EventArguments, other use a $args in the function, along with $args['something']->more.
    I get confused by all of that

    And I'm afraid to say that you need to get a basic understaning of objects/classes and MVC in order to fully understand it. Let me start with how events work in Vanilla.

    Each file you find in /applications/vanilla/controllers + models contains one class. The views are no classes but "simply" markup and as least as possible code to generate markup.

    ModelViewController in Vanilla is quite easy.

    The Controller

    If you open the page "/discussions/mine", the code that will be run could be found in the file class.discussionscontroller.php in the controllers folder. Just open that file and you will find that the class defines a method "mine". If you simply open "/discussions", Vanilla would use the method "index". It then fetches the needed information by using the model and displays it by using a view.

    The Model

    In order to show "mine discussions", there needs to be made a query to the database. DB queries are made from within a model. If you search for a class.discussionsmodel.php in the models folder you will find that finding the "correct" model or even the used model method is not as easy. You most probably would need to take a look at the method in the controller. But anyhow: if the controller needs to display data from the database it will fetch them by using a model.

    The View

    In nearly all cases when you look at a "yourforum.com/controller/method" scheme page you will find a /views/controller/method.php file in either /applications/conversations|dashboard|vanilla. Just a hint: all user related files should be in dashboard, all forum related (categories, discussions, comments) in vanilla.
    Those views consist mostly of markup, but sometimes there need to be more logic for building the markup. In such cases Vanilla tend to use a file with the name "helper_functions.php" that resides in the views subfolder.


    Now let's proceed to Vanillas events

    fireEvent() and EventArguments[]

    In any of those three files there might appear lines like $this->fireEvent('Whatever');. Most of them have one or more lines like $this->EventArguments['key'] = 'value'; before them.

    In order to use such an event, you need to look at the class name (which is equal to the file name). If you find a fireEvent in the DiscussionModel (e.g. "BeforeGet"), you can take influence on what is happening by adding a method public function discussionModel_beforeGet_handler($sender) {... to your plugin. If the fireEvent is in a e.g. the ProfileController, this would be the first part of the method.
    But views do not have such a class name and that's why you have to use the name of the controller which renders the view.

    What is the difference between $sender->EventArguments[] and $args[]?

    Easy: there is none. There is no difference at all. If you haven't defined the $args as a parameter, you will not be able to use that variable further on while $sender->EventArguments is always possible, but that doesn't count as a real difference I'd say. Let's use /applications/vanilla/views/discussion/discussion.php as an example:

    <?php
    /**
     * @copyright 2009-2014 Vanilla Forums Inc.
     * @license http://www.opensource.org/licenses/gpl-2.0.php GPLv2
     */
    
    if (!defined('APPLICATION')) {
        exit();
    }
    $UserPhotoFirst = c('Vanilla.Comment.UserPhotoFirst', true);
    
    $Discussion = $this->data('Discussion');
    $Author = Gdn::userModel()->getID($Discussion->InsertUserID); // UserBuilder($Discussion, 'Insert');
    
    // Prep event args.
    $CssClass = CssClass($Discussion, false);
    $this->EventArguments['Discussion'] = &$Discussion;
    $this->EventArguments['Author'] = &$Author;
    $this->EventArguments['CssClass'] = &$CssClass;
    
    // DEPRECATED ARGUMENTS (as of 2.1)
    $this->EventArguments['Object'] = &$Discussion;
    $this->EventArguments['Type'] = 'Discussion';
    
    // Discussion template event
    $this->fireEvent('BeforeDiscussionDisplay');
    

    If we like to add an extra css class to every discussion, we could do it any way we like:

    public function discussionController_beforeDiscussionDisplay_handler($sender, $args) {
        $sender->EventArguments['CssClass'] .= ' FirstAdditionalClass';
        $args['CssClass'] .= ' SecondAdditionalClass';
    }
    

    Our event hook is called with two variables (this is important!):
    $sender, which is the objet that fired the event (DiscussionController in our example)
    $args, which is the EventArguments array

    Since $args is the same as $sender->EventArguments, you can choose whatever you like. I tend to use $args since it is shorter.

    What about $sender->data()?

    Now it's getting really hard. I start with an extract of the discussion.php file from above, this time with some lines less.

    $Discussion = $this->data('Discussion');
    // (...)
    $this->EventArguments['Discussion'] = &$Discussion;
    // (...)
    $this->fireEvent('BeforeDiscussionDisplay');
    

    What is $this->data() and $this->EventArguments? $this is the DiscussionController and data() is a method of that controller, that's what could be told by looking at $this->data(). So please open the file class.discussionscontroller.php and try to find the method data...
    Nope you are not blind, it is not there. But look at the class definition: class DiscussionController extends VanillaController. That implicits that DiscussionController has all the methods that are defined in VanillaController and so you have to search for class.vanillacontroller.php and search for the data() method. Nothing here, but: class VanillaController extends Gdn_Controller. Now there is no class.gdn_controller.php. Whenever you find a class name Gdn_... in Vanilla, it is most probably part of the core (Vanilla uses its own framework which is called Garden). Open class.controller.php and finally we found a method called data()!

        public function data($Path, $Default = '') {
            $Result = valr($Path, $this->Data, $Default);
            return $Result;
        }
    

    If you have no idea what that means, search for "function valr" and you'll find that documentation: "Return the value from an associative array or an object. This function differs from GetValue() in that $Key can be a string consisting of dot notation that will be used to recursively traverse the collection.". It is defined like that function valr($key, $collection, $default = false) {
    So it searches for path in a variable called $this->Data. "What is $this->Data?" you ask and that is a good and important question. It is a variable defined in the class.controller.php, see at the top of the file.

        /** @var array The data that a controller method has built up from models and other calculations. */
        public $Data = array();
    

    So this array is like a storage for anything that a controller uses. It is most often used as temp storage when a controller renders a view. The controller stuffs information in this array and the view extracts it. This is their way to transport information. It is not done by using the array as you normally use an array ($this->Data[$key] = $value;), but with two functions:

    Controller: $this->setData('Title', 'Awesome View Title');
    View: <h1><?php echo $this->data('Title'); ?></h1>

    Remeber where you've used that function? $Sender->Data('Discussion.DiscussionID'). So with the knowledge of the controllers Data[] array and the data() method, we can now tell what this code does: it tries to find Data['Discussion]['DiscussionID']/Data['Discussion]->DiscussionID where Data[] is "this controllers internal storage array".

    Back to our event hook example: discussionController_beforeDiscussionDisplay_handler($sender, $args).
    Now that we have seen the code in the view:

    $Discussion = $this->data('Discussion');
    // (...)
    $this->EventArguments['Discussion'] = &$Discussion;
    // (...)
    $this->fireEvent('BeforeDiscussionDisplay');
    

    we can tell that the discussion is stored in the Data[] array of the controller. So all of this would be exactly the same:
    $sender->EventArguments['Discussion']->Name
    $args['Discussion']->Name
    $this->data('Discussion')->Name
    and even
    $this->Data['Discussion']->Name

    Final thoughts

    But if they are all the same, what should be used best? Is it really all the same? Nope, definetely not.

    Never access $this->Data array directly. There is setData() and data() to set and retrieve those values. If Vanilla Dev Team one day decide to change the way a controller can store and interchange data, that array might be abolished and those two helper functions will simply be tweaked to use the new storage method.
    Although I think this is very unlikely, there really is no reason to access this array directly, so don't do so.

    Only use $this->data('whatever') in a plugin if you have no other way to get access to that information. The reason is simmilar to the reason why you should not use the Data array: what is accessible with the data function might change. This is only for internal use and that's why the availability of this information is not guaranteed. Your plugin might be broken after an upgrade because some information is no longer available or - and that is much worse you expose sensitive data because the content of the data has changed.
    If you use it be extra careful and be prepared that this might need to be worked on after an upgrade.

    The EventArguments array is explicetly filled with data that is important for plugin developers. So it is recommended to use it. How you access it makes no difference at all and is completely up to you.
    Although you can use $sender->EventArguments, I'd recommend using $args mainly because it is more readable.


    This not of importance, but I prefer $args for another reason, too: $sender->EventArgument accesses an array of the calling class. If at anytime the way event data is passed to a hook will change, the data will surely still be send to the event hook as an argument. Anytime you use a functionality that Vanilla provides, you will be prepared even for bigger design changes of the underlying framework. But believve me: I consider myself being a nerd for thinking that way: there is not even a single sign of such serious changes!


    I'm sure there has been lots of stuff you already new, but since I don't know what your skills are I prefer explaining some more. Please don't feel disrespected or anything like that.

    Altogether this is some heavy stuff and if you have understand it, you might be able to answer that question by yourself:

    @Kiori said:
    This code, from my example above works when its on the QnA plugin, but doesn't outside of it, don't know why:

    $Answer = Gdn::SQL()->GetWhere('Comment', array('DiscussionID' => $Sender->Data('Discussion.DiscussionID'), 'Qna' => 'Accepted'))->FirstRow(DATASET_TYPE_ARRAY);
    

    But there are other reasons why this might have failed. Without seeing the context I wouldn't be able to answer that


    rbrahmsonKiori
  • R_JR_J Ex-Fanboy Munich Moderator

    Thanks to you I learned something awesome yesterday.
    BeforeDiscussionOptions works, but i figured: what if i wanted AfterDiscussionOptions?
    I learned that if i edit /applications/vanilla/views/discussion/index.php and add this:
    $this->fireEvent('AfterDiscussionOptions'); after WriteAdminCheck(); it just works. Awesome!
    Is there a way to do this as an extension, vs a 'hack' as you called it?

    Although that makes me shiver, I have to congratulate you for taking the least invasive way to change core files! If you really have no way to do what you like without changing a core file, inserting a fireEvent is the best way to do so.

    By the way: if you think that event can be useful for more developers, you could make a proposal for that. Either you make a pull request on GitHub or open an issue.

    In order to answer your question in a way that you take the most benefit from it I tell what I'm looking at when thinking about such a problem. But I think there are some ugly ways to achieve something like that but no elegant way.

    $this->fireEvent('BeforeDiscussionOptions');
    WriteBookmarkLink();
    WriteDiscussionOptions();
    WriteAdminCheck();
    
    echo '</div>';
    

    You need to insert something before the last div is what I understand that you try to achieve.

    Dig deeper

    Since you want to put something after WriteAdminCheck();, I take a look at that function. Maybe this function also fires an event and we can use that?

    if (!function_exists('WriteAdminCheck')):
        function writeAdminCheck($Object = null) {
            if (!Gdn::controller()->CanEditComments || !c('Vanilla.AdminCheckboxes.Use'))
                return;
    
            echo '<span class="AdminCheck"><input type="checkbox" name="Toggle"></span>';
        }
    endif;
    

    Nope, no event here.

    Suppress output between two events

    This one is not intuitive and doesn't work often. Look at the code from the event before you want to change something up to the next event. What if we would be able to make everything that is echoed between the upper fireEvent and the lower fireEvent echo nothing? If the output is only conditional that works from time to time.
    Simply by looking at the above function we could see that if we change the values of the variable and the config setting, we would be able to suppress the output. If this would be possible for all output, we could use event 1 to set the variables in a way that there will be no output and then in event 2 change the variables again and output everything in the order we like. Somewhat tricky, but when two events are fired and there is not much happening between them, chances are high that you have success.

    But we have no luck here:

    $this->fireEvent('BeforeDiscussionOptions');
    WriteBookmarkLink();
    WriteDiscussionOptions();
    WriteAdminCheck();
    
    echo '</div>';
    
    echo '<h1>'.$this->data('Discussion.Name').'</h1>';
    
    echo "</div>\n\n";
    
    $this->fireEvent('AfterDiscussionTitle');
    

    Whatever we do in BeforeDiscussionOptions, all those echo lines will be written to screen.

    Forcefully suppress output between two events! (fucking ugly and I don't know if it really works)

    Really no way to suppress them from being written to screen? Well, no. They will be echoed, yes but do you ever have heard of output buffering? Search php.net for ob_start.

    This is a little example

    ob_start();
    echo 'Hello World';
    $test = ob_get_clean();
    echo strtolower($test);
    

    So what if we start output buffering in event 1 and in event 2 delete what has been written to output? Then whatever happend between both events is not on screen. great. Afterwards you would have to simulate that output. Here is some untested code

    public function discussionController_beforeDiscussionOptions_handler($sender, $args) {
        ob_start();
    }
    public function discussionController_afterDiscussionTitle_handler($sender, $args) {
        // Forget everything that has been done by now!
        ob_end_clean();
    
        // Since everything is gone, it must be re-done.
        WriteBookmarkLink();
        WriteDiscussionOptions();
        WriteAdminCheck();
    
        echo 'YEAH! I can insert my custom markup anywhere I like now!'
    
        echo '</div>';
    
        echo '<h1>'.$this->data('Discussion.Name').'</h1>';
    
        echo "</div>\n\n";
    }
    

    One risk is that the output already gets buffered and that's why before you start buffering in event 1, you would have to get what is in the buffer by now, store it temporarily and after you have cleaned the buffer in 2, you would have to use ob_start again right after ob_end_clean and echo what you have temporarily stored.

    But keep in mind that everything that happens between those two events must be done twice, which takes time. This is really, really ugly. But I thought I tell you even about the ugly ways to achieve something like that.

    "Override" a function

    Back to WriteAdminCheck();. What if we could change it so that it not only does what is meant for, but also write our custom code? That would be handy. And we would only change one not very complex function. The function definition has that line on top if (!function_exists('WriteAdminCheck')): so if we definethat function in our plugin, our custom function would be used!
    If you add this below your plugins class you have "overridden" the function:

    function writeAdminCheck($Object = null) {
        if (!Gdn::controller()->CanEditComments || !c('Vanilla.AdminCheckboxes.Use'))
            return;
    
        echo '<span class="AdminCheck"><input type="checkbox" name="Toggle"></span>';
    
        echo 'Custom markup goes here';
    }
    

    Well, looks really easy, but what if this function is used somewhere else? Our markup would appear there, too. This might not be desired. So we should prevent it to appear anywhere else. Do you still have the file class.controller.php opened? There are some class variables that will be really helpful! See through them if you find one that looks promising to you, or simply read on ;)

    function writeAdminCheck($Object = null) {
        if (!Gdn::controller()->CanEditComments || !c('Vanilla.AdminCheckboxes.Use'))
            return;
    
        echo '<span class="AdminCheck"><input type="checkbox" name="Toggle"></span>';
    
        if ($sender->ResolvedPath != 'vanilla/discussion/index') {
            return;
        }
        echo 'Custom markup goes here';
    }
    

    That should do the trick.

    Replace the view

    Sometimes you'll be able to make a Vanilla controller use your view. Copy the index.php into /plugins/yourplugin/views/discussionindex.php add your new fireEvent line in your copy. Although we now have our own copy we should change only the minimum.

    Then look at the DiscussionControllers index method. The last command in there is $this->render(); this causes Vanilla to render the view that previously has been set. If no view has been set, it will try to find a view with the same name as the method in the views subfolder for this controller.

    If we like to trick Vanilla to use our altered view, we need to set the view manually. Look for the last fireEvent in the index method of the DiscussionController. We want to set our own view right at the end so if a view that has been set earlier will be replaced.

    For a better understanding, take at the class variables of the class.controller.php again:

    /** @var string Name of the view that has been requested. Typically part of the view's file name. ie. $this->View.'.php' */
    public $View;
    

    Its a public variable, so we can set it easily.

    public function discussionController_beforeDiscussionRender_handler($sender, $args) {
        $sender->View = $this->getView('discussionindex.php');
    }
    

    Ridiculously easy, isn't it?

    $this->getView() might need some additional explanation. $this is the current class = our plugin. Our plugin has some methods more than we declare in our plugin because it extends the Garden plugin class. getView() is a method which looks for the given filename in the plugins "views" subfolder. It returns the files path (which is required in the controllers $View variable)

    JavaScript

    Not my favorite, but it is a possibility. Output your markup at the end of the screen or in the near of where you would like to see itand change the DOM with JS as soon as the page has finished loading.
    You have to ponder if the above shown possibilities are too dangerous/too invasive and/or if JS might be a good alternative. It's up to you.

    And the winner is...

    I don't know. I would advice you to either override the function, the view or use JavaScript.
    JS is least invasive, but I personally do not like rendering to screen and changing it afterwards. Feels wrong.
    Overriding complete views bears the danger that after an upgrade your plugin prevent users from seeing new features or even showing them a messed up screen.
    Overriding a function feels not good either. What if anyone else had the same idea? If two plugins override the same function it couldn't work.

    I'm happy that this is your decision and I only had to show you the possibilities ;)


  • R_JR_J Ex-Fanboy Munich Moderator

    I forgot something in the above:

    CSS

    Add your markup where you can add it and hope that you are able to use CSS to push it where you like it to be. That would be the best way, without a doubt.

    And I forgot it... O.o

    The next one is easy!

    In that same vein, of avoiding editing the original files, what if I want to edit something that is a text, like the 'Most recent by' in the post listing, and make it a link to discussion#latest?
    I don't really like how the posts always go to the latest discussion, but I don' want that option to go away.
    It's not obvious to me if this type of "extension" is even possible, but it may come in handy.
    I know the original text can be hacked of course.

    No, it is not possible. You could do it by overriding the view, but this time we are lucky:

                       $Sender->fireEvent('AfterCountMeta');
    
                        if ($Discussion->LastCommentID != '') {
                            echo ' <span class="MItem LastCommentBy">'.sprintf(t('Most recent by %1$s'), userAnchor($Last)).'</span> ';
    
    

    There is nothing between what you like to change and the fireEvent. So simply add he code you would like to see and hide with CSS what you do not like.


    Kiori
  • R_JR_J Ex-Fanboy Munich Moderator

    I haven't understood that "cycle" idea. You search for a way to make all comments accessible right from the discussion, correct?

    I think it would be handy if all answers (as well as the question) are accessible from the question and from every answer.

    A module might be a solution (modules are the boxes in the sidebar)

    Or something like

    Question       1st Answer     2nd Answer     3rd Answer
    By UserName    By UserName    By UserName    By UserName
    

    or

    Question (Avatar)   Answer (Avatar)   Answer (Avatar)   Answer (Avatar)
    

    below the question and each answer (or even each comment?)

    But I'm not very creative when it comes to UI questions...


    Kiori
  • @R_J
    I found the problem with the sql request I mentioned, it was a typo. I opened it up in the right editor and spotted it right away. ($Sender vs. $sender).

    I got crazy with all the things you taught and was able to modify everything without having to edit core files. ;)

    I'm linking the plugin that makes the edits i wanted(LatestComments).
    It clears all the meta info in the discussions list, and only displays what is relevant when it's relevant. Furthermore, now posts link to their 1st post, but you can click the 'Most recent by', if you wanna see the latest.
    Had to use lots of those use the event after or before ideas there. It's a handy plugin if anyone wants it.

    The Answer Buttons is a work in progress, but the basic functionality I wanted is there.

    About the js i think i'm going to do a floating button.
    After a certain % of the screen it floats to the side in a fixed position and becomes a button that hovers with you. But that's all js doing it's thing(I'll likely use jquery), so no mystery there.
    I'll post the feedback if i get it done or get involved in any hiccups.

    Thanks so much. :+1:

    R_J
Sign In or Register to comment.