May 07 2019
May 07

Drupal 8.7 was released couple of days ago on May 1, 2019. As you might know, new features are added with each minor release of Drupal 8 (e.g. between 8.6 and 8.7) which occur in 6-month intervals. Originally 8.7 was supposed to be released in March 2019. But the timing of Drupal's releases has historically occurred 1-2 months before Symfony's releases, which forces Drupal community to wait six months to adopt the latest Symfony release. In order to be able to adopt the latest Symfony releases faster, Drupal community shifted Drupal's minor releases to May and December in a plan to allow adoption of latest Symfony releases within a month.

This is penultimate version of Drupal 8, which will be concluded with Drupal 8.8 in December 2019, after which we expect release of Drupal 9 sometime in June next year!

Beside bug fixes and dependency updates lets see what new features Drupal 8.7 brings!

Revisions

Taxonomy terms and custom menu links are now revisionable, which allows them to take part in editorial workflows which was until now only possible for Content types and Custom blocks.

 

JSON:API in Core

Drupal 8.7 will provide an out-of-the-box JSON:API implementation, marking another major milestone towards making Drupal API-first.

Now you will be able to generate an API server that implements the JSON:API specification with zero configuration. Once you enable the module, you are done.

Developers and content-creators can use it to build both coupled and decoupled applications and pull content from Drupal into iOS and Android applications, chatbots, decoupled frontends such as ReactJS, voice assistants and many more!

Layout Builder module is now stable

Layout Builder module was originally added as an experimental core module in Drupal 8.5 and is now stable and ready for production use!

layout builder

If you haven’t heard about it Layout Builder is offering a single, powerful visual design tool for site builders to create templated layouts and custom landing pages.

 

PHP 7.3 Is Now Supported

PHP 7.3 was released in December 2018 and comes with numerous improvements and new features. Also with this release new Drupal sites can only be installed on PHP 7.0.8 or later. Installing Drupal on older versions results in a requirement error.

Drupal PHP 7.3

 

However, existing sites will still work on at least PHP 5.5.9 for now, but will display a warning

PHP stopped supporting version 5.5 on July 21, 2016 and Drupal security updates will begin requiring PHP 7 as early as Drupal 8.8.0 (December 2019), so all users are advised to update to at least PHP 7.0.8 now or preferrably to PHP 7.3.
 

GDPR

As part of continuing GDPR compliance improvements in Drupal core, Comment module no longer logs IP addresses for comments by default. Existing sites will still continue to log IP addresses but this can be changed by changing comment.settings.log_ip_addresses to FALSE in the site configuration using settings.php.

 

This was just a short brief into the new features. For a full list take a look at official release notes: https://www.drupal.org/project/drupal/releases/8.7.0

 

Apr 10 2019
Apr 10

One of our clients had a request where he wanted to track and filter changes on some entities per field level so he can filter them and check new and old values and react only if they had some value that was interested to him.

Now if we're talking about revisions then we track everything and client wanted to track only changes on some fields and not on whole entity in general. This led us thinking and we created a module for tracking changes on entities. The module is called Entity log and you can find it on this link below:

https://www.drupal.org/project/entity_log

When you install the module you can go to /admin/config/entity-log and setup which fields you want to track in log and select where you want to log changes (watchdog or Entity log entity).

It is simple enough for user (admin or moderator) to select fields he wants to track. (Check the image below)

selected fields

After checking what you want you can create view that will be your log viewer. So lets do that below:

log view

We select fields we want in our view like below and we have new entity log tracker.

entity log tracker

Since this is a drupal entity you can create any kind of view that suits your needs for logging and with fields you want to be included. Hope you enjoy the module.

Jul 18 2017
Jul 18

This tutorial is for managing Drupal (>=8.1) with composer.

What is composer?

Git practices:

put /vendor in .gitignore

put /modules/contrib in .gitignore

Install site:

composer install

Modules:

composer config repositories.drupal composer https://packages.drupal.org/8

ALWAYS install modules using composer. Forget about Drush or even manual download!!

composer require drupal/{module_name}:{module_version}
Where {module_version} example:

8.x-1.1 => 1.1

8.x-2.2.0 => 2.2.0

We can support updates by saying 1.* which means next time you make composer install or update it will update to newer minor version if exists, but won't update to 2.x.

Dont's:

Don't install module manually or through Drush.

Don't change anything in modules source code. Patch it and add patch through composer.json.

Don't use composer manager module, this is no more needed as of Drupal 8.1

Uninstall a module:

Always uninstall a module in this 2 step way:

drush pm-uninstall {module_name}
composer remove drupal/{module_name}

Other:

You can always see latest library and module versions installed in composer.lock file.

Deploying modules to other devs or production:

Now you added a module locally through composer and want to push it? Since modules are now ignored in git, only thing that will be pushed is depenency in composer.json. Therefore to deploy the module, each developer will need to rebuild its dependencies using 

"composer install".

Drupal configuration manager will take care if the module is enabled or not. However order must be respected so you should import new configuration only after composer install is run, otherwise you might get a error.

So good practice after git pull is:

composer install

drush cim

drush cr

Drupal 8 composer starterkit:

FAQ:

Q:What if I downloaded a module manually or through Drush?

A: Just re-add it using composer require drupal/{module_name}:{module_version}

Make sure its in modules/contrib folder.

Jul 18 2017
Jul 18

Export configuration:

drush cex

Import configuration:

drush cim

IMPORTANT!

When working on project with other developers ALWAYS do following:

git pull

drush cim

drush cr

{...DO YOUR WORK...}

drush cex

git push (if there where commits in meantime you will have to do git pull)

Not following this order might delete your own or your colleagues work! Try to keep {...DO YOUR WORK...} as short as possible, having it in this state for couple of days might bring you conflict when trying to merge.

May 22 2017
May 22

Drupal Heart Camp Zagreb was a success with over 100 attendees from 10 countries. Keynote presentations covered topics such as how it is to work in geo-distributed development teams, advantages and disadvantages of the freelance business and the importance of web accessibility.

We have held three sessions. The presentation "Drupal Stocks and Options" was held by Kristijan Lukačin on the CxO day, and the other two called "Docker4Drupal 2.0 for development and Using Search API" and "Search API Solr and Facets in Drupal 8" were held by our lead developer Dalibor Stojaković. We have prepared the presentation slides for you used in all three presentations.

Drupal Stocks 'n' Options

 

Docker4Drupal 2.1 for Development

 

Using Search API, Search API Solr and Facets in Drupal 8

May 17 2017
May 17

In this article, I'll show how you can create a custom checkout pane for Drupal Commerce in Drupal 8. For this purpose, we'll create a checkout pane with a configuration form and the ability for users to add coupons to their order.

Creating a custom checkout pane

Checkout pane needs to be created with the correct annotation and in the correct namespace. We'll create CommerceCoupons class in module_name/src/Plugin/Commerce/CheckoutPane directory with annotation like in code below.


namespace Drupal\module_name\Plugin\Commerce\CheckoutPane;

use Drupal\commerce_checkout\Plugin\Commerce\CheckoutFlow\CheckoutFlowInterface;
use Drupal\commerce_checkout\Plugin\Commerce\CheckoutPane\CheckoutPaneBase;
use Drupal\commerce_checkout\Plugin\Commerce\CheckoutPane\CheckoutPaneInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Form\FormStateInterface;

/**
 * Provides the coupons pane.
 *
 * @CommerceCheckoutPane(
 *   id = "coupons",
 *   label = @Translation("Redeem Coupon"),
 *   default_step = "order_information",
 * )
 */
class CommerceCoupons extends CheckoutPaneBase implements CheckoutPaneInterface {


/**
 * {@inheritdoc}
 */
public function __construct(array $configuration, $plugin_id, $plugin_definition, CheckoutFlowInterface $checkout_flow, EntityTypeManagerInterface $entity_type_manager) {
  parent::__construct($configuration, $plugin_id, $plugin_definition, $checkout_flow, $entity_type_manager);
}

This means that our custom checkout pane will have an id 'coupons' and will be labelled as Redeem Coupon and will be set on order information step by default.

First, we'll create the settings (configuration) form for our pane. For this purpose, we'll have settings if we, for example, want to set up that user has the ability to redeem only one coupon on order. For this, we'll have to implement four functions.

First, we'll setup our default configuration.


/**
 * {@inheritdoc}
 */
public function defaultConfiguration() {
  return [
      'single_coupon' => FALSE,
    ] + parent::defaultConfiguration();
}

Then we'll implement summary for our configuration form.


/**
 * {@inheritdoc}
 */
public function buildConfigurationSummary() {
  $summary = !empty($this->configuration['single_coupon']) ? $this->t('Single Coupon Usage on Order: Yes') : $this->t('Single Coupon Usage on Order: No');
  return $summary;
}

Next, we'll build our configuration form.


/**
 * {@inheritdoc}
 */
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
  $form = parent::buildConfigurationForm($form, $form_state);
  $form['single_coupon'] = [
    '#type' => 'checkbox',
    '#title' => $this->t('Single Coupon Usage on Order?'),
    '#description' => $this->t('User can enter only one coupon on order.'),
    '#default_value' => $this->configuration['single_coupon'],
  ];

  return $form;
}

And save these settings on submit.


/**
 * {@inheritdoc}
 */
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
  parent::submitConfigurationForm($form, $form_state);

  if (!$form_state->getErrors()) {
    $values = $form_state->getValue($form['#parents']);
    $this->configuration['single_coupon'] = !empty($values['single_coupon']);
  }
}

You can see how this will look like on Checkout flow edit page.

flow edit

Next, we'll build our pane form. We'll check our configuration, and if the order has a coupon applied on it and has config setup for the single coupon, won't show form.


/**
 * {@inheritdoc}
 */
public function buildPaneForm(array $pane_form, FormStateInterface $form_state, array &$complete_form) {
  $order_has_coupons = $this->order->coupons->referencedEntities();
  if($this->configuration['single_coupon'] && $order_has_coupons){
    return $pane_form;
  }
  $pane_form['coupon'] = [
    '#type' => 'textfield',
    '#title' => $this->t('Coupon'),
    '#default_value' => '',
    '#required' => FALSE,
  ];
  return $pane_form;
}

After that, we'll implement validation for our coupons and check if a valid coupon is provided and if it can be applied to this order.


/**
 * {@inheritdoc}
 */
public function validatePaneForm(array &$pane_form, FormStateInterface $form_state, array &$complete_form) {
  $values = $form_state->getValue($pane_form['#parents']);
  if(!empty($values['coupon'])){
    $coupon_code = $values['coupon'];
    /* @var \Drupal\commerce_promotion\Entity\Coupon $commerce_coupon */
    $commerce_coupon = $this->entityTypeManager->getStorage('commerce_promotion_coupon')->loadByCode($coupon_code);
    $valid = false;
    if($commerce_coupon){
      $promotion = $commerce_coupon->getPromotion();
      $valid = $promotion->applies($this->order) ? true : false;
      if($valid){
        foreach($this->order->coupons->referencedEntities() as $coupon){
          if($commerce_coupon->id() == $coupon->id()){
            $form_state->setError($pane_form, $this->t('Coupon already applied to order.'));
          }
        }
      }
    }
    if(!$valid){
      $form_state->setError($pane_form, $this->t('The specified coupon does not apply for this order.'));
    }
  }
}

What is left for us is to save coupon on order and process coupon. For this, we'll use commerce promotion processor.


/**
 * {@inheritdoc}
 */
public function submitPaneForm(array &$pane_form, FormStateInterface $form_state, array &$complete_form) {
  $values = $form_state->getValue($pane_form['#parents']);
  if(!empty($values['coupon'])){
    $coupon_code = $values['coupon'];
    $commerce_coupon = $this->entityTypeManager->getStorage('commerce_promotion_coupon')->loadByCode($coupon_code);
    if($commerce_coupon){
      $this->order->coupons[] = $commerce_coupon->id();
      $coupon_order_processor = \Drupal::service('commerce_promotion.promotion_order_processor');
      $coupon_order_processor->process($this->order);
      $this->order->save();
    }
  }
}

We're done and we can test our new checkout pane.

Apr 26 2017
Apr 26

Webform is a module that provides the ability to make a large number of customised surveys for end-users to fill out for which Webform was a more suitable solution than creating content types and using CCK or Field module. In our recent project a need has risen to make customisations of the module to suit the needs for our client (see the case study). We needed to expose Webform submissions to views. The customisations were coded by Alexander Trotsenko, one of our senior developers. A new module was born - Webform Views Integration module, designed to be a good starting point for various Webform submission related views handlers for anyone wanting to code any special or custom views integration for Webform.

Active maintainer of the Webform module Jacob Rockowitz contacted us and praised our developer Alexander Trotsenko and his efforts in regard to Webform Views Integration module. Jacob expressed his wish to meet Alex, shake his hand and buy him a well deserved beer! :D

This is what Jacob said:

What I appreciate most about the Webform Views Integration module is that it is addressing a key requirement for the Webform 8.x-5.x ecosystem, while allowing me, as the maintainer of Webform module, the flexibility to still push the Webform module's API forward.  The quality of Alexander's (bucefal91) work is top notch and more importantly, he gets Drupal 8 and understands how to navigate Drupal and the Webform module's APIs.  In the Drupal community being able to integrate a continually evolving API like the Webform 8.x-5.x module with the Drupal's Views module is a true challenge, that Alexander and the Websolutions Agency has stepped-up to build and maintain; he deserves a round of applause.

Currently the Webform Views Integration module is reported to be used by 266 sites and rising. In case you want to meet any of us, you can do so next month at the Drupal Heart Camp Zagreb (a new kind of Drupal camp experience taking place May 19-21).

Apr 06 2017
Apr 06

Along with the University Computing Centre - SRCE (acronym SRCE is also a Croatian word for HEART), and Drupal Croatia Association we have teamed up to bring you a completely new and different Drupal camp experience - Drupal Heart Camp!

SRCE has a long tradition in the area of information and communication technologies. It was founded in 1971 within the University of Zagreb with the purpose to enhance the implementation of information technologies in the academic community as well as in Croatia in general. Drupal Croatia Association is a non-profit organisation. Its mission is to strengthen and support the Drupal community in Croatia, promote the project Drupal and support expansion of network of Drupal users through organisation of Drupal conferences and meetups. We are thrilled to be co-organising the camp with the good people from SRCE and Drupal Croatia Association. Together we are working to ensure that the Drupal Heart Camp Zagreb will not only be filled with great, interesting and fun events, lectures and presentations, but will also be spiced up with our traditional cuisine! You will have the opportunity to familiarise yourself with our culture and experience it all in beautiful natural scenery. There are numerous interesting venues that can be explored in Zagreb, which is recognised by the ever increasing number of tourists each year.

About the Drupal Heart Camp Zagreb

For more information about the camp, venue, sessions and tickets, you might want to visit the official website: drupalheart.com.
See you there!

Mar 20 2017
Mar 20

The idea of Drupal 8 is to avoid writing HTML directly in PHP code when making a custom module. In this example, we will show you how to create a custom block programmatically, create a custom Twig file and render desired variables to a template.

Follow the next steps:

Create your custom .module file if you don't have one and add hook_theme() with defined variables names, and Twig template name.

/**
 * Implements hook_theme().
 */
function ws_custom_theme() {
  return array(
   'ws_custom_block' => array(
            'variables' => array('title' => NULL, 'description' => NULL),
            'template' => 'block--ws-custom',
        ),
  );
}

Next step is to create a block file and place the code. Go to your custom module folder, open /src/Plugin/Block/ and create file e.g. WSCustom.php. Include some core functions and build your custom block. Define id for your block and admin label, so you can easily find it in Structure -> Block layout and place to a region.

Create a class and extend BlockBase. Use build() function and return an array of variables:

namespace Drupal\ws_custom\Plugin\Block;

use Drupal\Core\Block\BlockBase;

/**
 * Provides a 'ws custom' block.
 *
 * @Block(
 *   id = "ws_custom_block",
 *   admin_label = @Translation("WS Custom Block"),
 *
 * )
 */
class WSCustom extends BlockBase {
  /**
   * {@inheritdoc}
   */
  public function build() {
  // do something
    return array(
      '#title' => 'Websolutions Agency',
      '#description' => 'Websolutions Agency is the industry leading Drupal development agency in Croatia'
    );
  }
}

Now clear cache and go to Structure -> Block layout. Find your block and place it in the region you want.

Next step is to create Twig file and render variables. In your themes folder open the theme that you use e.g. wstheme and open folder templates/block. Now, create a file block--ws-custom.html.twig (we defined the namespace in the array 'template' in the first example). 

Render variables to Twig HTML:

{#
/**
 * @file
 * Profile ws custom block.
 */
#}
 <div class="col-sm-3 ws-custom--block">
    <h1>{{ title }}</h1>
    <p>{{ description }}</p>
 </div>

If you run into a problem, we are here to help. Just leave a comment.

Mar 16 2017
Mar 16

Drupal behaviors documentation suggests that we use the "once" method to prevent binding JavaScript event handlers multiple times on Ajax requests.

Prevent multiple binding of JavaScript event handlers

If you use event listener functions in Drupal behaviors, use .once() function to avoid duplicate handlers after running Ajax requests.

In this example, clicking on a button element toggles a paragraph, and it will work well if you don't run any Ajax requests on the same page. But if you do, Drupal.behaviors will run again on the button and click listener will be added multiple times. Consequently the paragraph will be toggled multiple times.

Drupal.behaviors.ws_custom = {
    attach: function (context, settings) {
      $('button').click(function() {
        $("p").toggle();
      });
    }
  };

Since Drupal integrates the jQuery Once plugin in its core since Drupal 7, to prevent the paragraph to toggle multiple times simply change your code to this:

Drupal.behaviors.ws_custom = {
    attach: function (context, settings) {
      $('button').once().click(function() {
        $("p").toggle();
      });
    }
  };

Now, click listener will be added only once to the element button and you will prevent duplicating JavaScript in behaviors.

Alternative solution

Another way to solve this problem is to run the unbind function before adding event listener function to the element. For example:

Drupal.behaviors.ws_custom = {
    attach: function (context, settings) {
      $('button').unbind("click");
      $('button').click(function() {
        $("p").toggle();
      });
    }
  };

If you have another examples, leave them in the comments below.

To see more about writing jQuery in Drupal and using Drupal behaviors click here.

Mar 15 2017
Mar 15

In this example we show how to add a custom button to a user edit form with its own function submission handler. What needs to be done is to disable a user from loading FormStateInterface using entity, set the field value and save it.

Create a custom module (in this example: ws_custom) and use namespaces:

  • use Drupal\Core\Form\FormStateInterface;
  • use Drupal\Core\Form;

In the custom module you will need to implement Drupal hook: hook_form_alter():

  1. Create new form action (in this example: 'disable')
  2. Set form '#type' as submit to create button or link handler
  3. Set '#limit_validation_errors' as empty array to skip required fields in this altered form (or add your own validation for this submission)
  4. Add array of form functions to form '#submit'
  5. Use '#value' to set button (link) title
  6. Optional set '#button_type' as danger to add 'button--danger' class on html element
  7. By setting '#weight' value to form we are changing order of fields while displaying form (in this example, field will be added last, because of 999 weight)
<?php
/**
 * Implements hook_form_alter().
 */
function ws_custom_form_alter(&$form, FormStateInterface $form_state, $form_id) {
        switch ($form_id) {
          case 'user_form':
                $form['actions']['disable'] = array(
                        '#type' => 'submit',
                        '#weight' => 999,
                        '#limit_validation_errors' => array() ,
                        '#button_type' => 'danger',
                        '#submit' => array(
                                'ws_custom_disable_account_form'
                        ) ,
                        '#value' => t('Disable account') ,
                );
          break;
}

Call function form alter and pass $form and $from_state.

Load entity using function getFormObject on FormStateInterface and get its entity using getEntity function.

Now change field with set('field', 'value') function (in this case we are setting 'status' field to '0' to disable or block user) and save this entity with save() function:

function ws_custom_disable_account_form(&$form, FormStateInterface $form_state) {
    $entity = $form_state->getFormObject()->getEntity();
    $entity->set('status', '0');
    $entity->save();
}

If you want to provide another example or add to this one, post a comment. ;)

If you run into a problem - we are here to help.

Mar 09 2017
Mar 09

When you're creating modules in Drupal 7, most likely you'll end up creating some kind of configuration forms for your module. In Drupal 7 the easiest way to do this is through variables.

Since this is the easiest way, most people use them but don't overuse them since they're loading on every page load, which is why we don't want to have many variables in the variables table.

How to create configuration forms in Drupal 7

There is a bad way and a better way to create configuration forms in Drupal 7, and I'll use both ways to show the difference. First let's start with the bad way, which is to simply create a form with each item having its own variable. 

function custom_table_form_admin_settings() {
  $users = [
    'anonymous',
    'authenticated'
  ];
  foreach($users as $user) {
    $form['comparison_table_'.$user.'_q1'] = array(
      '#type' => 'textfield',
      '#title' => t('First quote '. ucfirst($user)),
      '#description' => t('First quote.'),
      '#default_value' => variable_get('comparison_table_'.$user.'_q1', ''),
      '#required' => FALSE,
    );
    $form['comparison_table_anonymous_'.$user.'_q2'] = array(
      '#type' => 'textfield',
      '#title' => t('Second quote ' . ucfirst($user)),
      '#description' => t('Second quote.'),
      '#default_value' => variable_get('comparison_table_'.$user.'_q2', ''),
    );
  }
  return system_settings_form($form);
}

The form will look like this:

Optimising configuration forms fig. 1

As you can see each item has it own variable. In the process you could also do something like iterate through some of your system items and generate configuration for them. That would probably result in about 100+ variables or as many config items as you want the configuration for in the variable table. Of course, this is a bad practice and it will result in slow page load since variables are loaded on every page load. The form looks ugly, and you'll have a bunch of variables to delete afterwards on uninstall. Also if your variable is related to some items that gets deleted you end up having to delete it on appropriate hook into your process.

Now there is a better way to do this. So If you need to have configuration create a fieldsets and use #tree property. For example:


function custom_table_form_admin_settings() {
  $users = [
    'anonymous',
    'authenticated'
  ];
  $comparison_table_settings = variable_get('comparison_table_settings','');
  $form['comparison_table_settings'] = array(
    '#type' => 'fieldset',
    '#title' => t('Comparison table settings'),
    '#description' => t('Comparison table settings for users'),
    '#collapsible' => TRUE,
    '#collapsed' => FALSE,
    '#tree' => TRUE,
  );
  foreach($users as $user){
    $form['comparison_table_settings'][$user] = array(
      '#type' => 'fieldset',
      '#title' => t(ucfirst($user) .' user'),
      '#description' => t('Comparison table settings for anonymous user'),
      '#collapsible' => TRUE,
      '#collapsed' => TRUE,
    );
    $form['comparison_table_settings'][$user]['q1'] = array(
      '#type' => 'textfield',
      '#title' => t('First quote'),
      '#description' => t('First quote.'),
      '#default_value' => isset($comparison_table_settings[$user]['q1']) ? $comparison_table_settings[$user]['q1'] : '',
      '#required' => FALSE,
    );
    $form['comparison_table_settings'][$user]['q2'] = array(
      '#type' => 'textfield',
      '#title' => t('Second quote'),
      '#description' => t('Second quote.'),
      '#default_value' => isset($comparison_table_settings[$user]['q2']) ? $comparison_table_settings[$user]['q2'] : '',
    );
  }
  return system_settings_form($form);
}

This will result in only ONE variable which will be only one row in variable table and that is comparison_table_settings which will be updated when you save config form. If we take a look at dump from variable we see how the unserialised variable looks like. So only one unserialize() function will be called. If you load your items from something in your system you don't have to hook anywhere to delete variables because you only have one.

Optimising configuration forms fig. 2

Our new form looks better, it's grouped by fieldsets and we only have one variable for all configuration items.

So once we want to uninstall the module we can perform only one variable_del in hook_uninstall and it will delete our complete configuration.

Keep this in mind when you're working on Drupal 7 modules.

Mar 02 2017
Mar 02

Using flags on large datasets allows for user customisation. A user can flag nodes, comments, users, and any other type of entity. Some possibilities include bookmarking, flagging friends, flagging important, visited/seen or offensive content.

In this blog post I'll show you how you can index flags using Search API and how you can use them in indexed view. This is possible with the Flag Search API module which I created last week for our project (sponsored by Websolutions Agency). Flag Search API module has dependencies to Flag and Search API module.

Now let's see how this works. For testing we'll use the following modules:

Our use case here will be to display all Articles that are bookmarked by a logged in user.

First we need to download all modules on our Drupal installation and set them up. We can install them with composer (preferable).

composer require drupal/<module name>

Alternatively, download them with drush or download them manually.

After installation with composer we'll have to install (enable) them using drush:

drush en <modulename>

Or manually go to Admin / Modules link in your Drupal installation.

flags-1

flags-2

After modules are enabled let's create a new flag called Bookmark, so we need to go to Administration / Structure / Flags and click Add flag.

flags-3

Selecting Flag Type Content and clicking continue Link where we'll set up some basic things about our flag. Label will be "Bookmark", Scope is Personal and we'll not set up Flag access for the purpose of this blog. Flag link text will be “Bookmark this item” and Unflag link text will be “Unbookmark this item”.

flags-4

Let's set the flag to Display link as field and Link type will be AJAX link.

flags-5

Now let's save this flag and move on. For my testing environment I'll need to have some content created so I'll use Devel generate and generate about 50 article nodes for this.

flags-6

Now that we've created some content I can go to any page and bookmark it. I've placed bookmark link just below the title for testing purposes.

flags-7

After bookmarking content we’ll have to add it to create index and add it to the index so that we can create a view for bookmarked articles page. For index I'll use my existing Apache Solr server. You can add connection to your server on Administration / Configuration / Search and metadata / Search API from backend. After that I’ll add index called "Content Articles" and in Bundles select Article to be indexed.

flags-8

Now we can see our newly created index and we can set some fields on it.

flags-9

Let's check out fields section.

flags-10

Right now there are no fields because we didn't select any to be indexed. Let's add a field and our flag. I'll click on Add fields and under General select Rendered HTML output to our fields.

flags-11

After saving we now have one field for index.

flags-12

Notice there still isn't any flags here so let's save the changes and move to Processors tab to see what we have there.

flags-13

As you can see here there is a Flag indexing processor. Once we enable it we'll see that it moves to preprocess index section and now we have options to enable which flags we want to index.

flags-14

Here we'll check our created Bookmark flag and click on Save. Now you can see that there are two fields for index. One is Rendered HTML output and the other one is tflag.

flags-15

Let's go to View tab and index this manually by clicking Index now.

flags-16

Now when all is indexed we can make a view that will show only bookmarked articles. Let's create a view called "Bookmarked Articles" with a page called the same like shown below.

flags-17

flags-18

Next let's add a contextual filter. As you can see here, there is an option in the contextual filter to filter by Bookmark (which is our Bookmark flag).

flags-19

Select Bookmark and click on Add and configure contextual filters.

Now there is an option to Provide default value for bookmark and this is actually User ID from logged in user. Since user flags are flagged by user we only need to select logged in user as a default value and we’ll get all content bookmarked by the logged in user.

flags-20

And here we go. Since I am using admin user role and admin is a logged in user, it is showing only articles flagged (bookmarked) by admin.

flags-21

This was an example of how you can create flags, index them through Search API into Apache Solr using Flag Search API module and create custom pages based on your flagged content.

Feb 22 2017
Feb 22

We are a Drupal born company, created for one purpose - Drupal.

How we've built our Drupal company

Believe it or not, but we've started our Drupal business based on community knowledge and insights gained on Drupal Camps and Drupal Cons sessions and all the conversations we've had there spiced with love for open source and Drupal development in general.

In the video below Kristijan will walk you through our valuable experiences gained when we started our Drupal business. He will give you practical instructions based on our trials and errors. Learn about the greatest challenges that we've overcome while having a 100% growth (so far). Kristijan explains how we "invested in Drupal stocks and options"- i.e. how we've built our Drupal business.

Kristijan's session "Drupal Stocks & Options" held at Drupal IronCamp in Prague

[embedded content]

Watch it on YouTube https://youtu.be/7IjzW8yBoD0

Oct 20 2016
Oct 20

We understand the importance of getting involved by contributing back and supporting the Drupal community. Drupal Contrib Days have in fact become a common practice in our agency. It's where we all split up into teams and each team collaborates on contributing back to Drupal core or to various projects or modules.

Drupal contrib day at the agency

contribe-day-1contribe-day-2

Websolutions team in the middle of a Contrib Day session.

For example, during the last Drupal contrib session one of our teams worked on porting Locker module to Drupal 8. Locker is a site authentication module that uses session to forbid access and serves as an additional layer to hide a Drupal site from public.

Another team contributed to project called Video Embed Screencast, a submodule of Video Embed Field module, which is a quick way to embed Screencast.com video in Drupal. It creates a simple field type that enables you to embed videos from Screencast.com simply by entering a video URL.

Another team worked on a project called Commerce Neteller, which is a Neteller integration for Drupal Commerce payment and checkout system that supports online payments using Neteller API.

Next project we contributed to was AudioField module. Audiofield adds a new CCK field that allows you to upload audio files and automatically displays them in a selected audio player.

The last project we contributed to was JSON Editor, which is a tool based on JSON editor library for viewing, editing, formatting, and validating JSON.

Actively contributing to the projects we use on daily basis has become an essential part of what we do. Contributing is the foundation of open source and contrib days are something that all of us look forward to. It helps projects move forward and stay competitive, it helps the entire Drupal ecosystem, and in the end it helps our customers.

Oct 17 2016
Oct 17

Locker module is a Drupal authentication tool originally developed by our team. It uses sessions to forbid access to visitors and to hide a Drupal website. A user is required to login to gain access to the website. While Locker module isn’t a replacement for Drupal authentication, it serves as an additional layer to hide your Drupal website from public. It’s an alternative to HTTP Auth standard and recommended to be used in case your server doesn’t support HTTP Auth or if you don’t have permission to set it up or in case you want additional features that Locker module provides.

Drupal authentication use cases

There are multiple cases when you could use Locker:

  • An alternative to HTTP Auth
    Use Locker in case your server doesn’t support HTTP Auth or you don’t have permission to set it up.
  • Drupal maintenance mode replacement
    Use Locker instead of using Drupal maintenance mode.
  • Hiding a Drupal site from public on development and staging server
    Take proactive measures to keep your dev and staging sites from showing up to public and in Google search results.
  • White label development
    Customise Locker with your own brand, logo and identity.
  • Hiding a site from Google Analytics
    Use Locker to hide your Drupal site from Google Analytics.
  • Post-update site verification as an anonymous user
    Verify whether cache works correctly after an update.

How to use Locker module

After installing, access Locker module in admin/config/development/locker.

To lock your Drupal site:

  • Choose radio button "Yes"
  • Choose login option
  • Click "Submit"

WARNING: This will lock your Drupal site immediately! Use your credentials to gain access. If you forget your credentials you need to use drush unlock. Files will still be accessible via direct links!

To unlock your Drupal site:

  • Choose radio button "No"
  • Click "Submit"

drupal-authentication-tool-unlock-username-password

drupal-authentication-tool-unlock-passphrase

drupal-authentication-tool-sign-in-unlock

drupal-authentication-tool-locked

Visit the official Locker module project page - https://www.drupal.org/project/locker

Mar 03 2016
Mar 03

drupal-history.

How has Drupal evolved from a message board platform (Drop 1.0) to a fully scaled enterprise-level CMS (Drupal 8.0)?

Did you know some of the key features like modules, nodes, watchdog and multilingual support have been available since Drupal 2.0? Follow the timeline of the Drupal history in the presentation "History of Drupal (aka from Drop 1.0 to Drupal 8.0)" that was held at DrupalCamp London and at Drupal Developer Days Milan in 2016. After watching this presentation you will have a better understanding of why some of the Drupal architecture has been done the way it has.

Drupal history presentation/session topics

  • The web before Drupal
  • How has Drupal survived massive changes in web industry?
  • Why and how was Drupal created?
  • Tour through the history from Drupal 1.0 to 8.0 and a retrospective of how the key features we use today evolved over time (eg: concept of modules, nodes and multilingual support)
  • The future of Drupal

Unfortunately, on Drupal.org website there aren't any releases of Drupal prior to Drupal 4.0. However, the code is kept in the Git, and it just needs to be tagged. To make it happen, vote for this issue here: https://www.drupal.org/node/2660622. Nevertheless, we prepared for you a GitHub repository where you can browse the source code and download old releases from Drupal 1.0 to Drupal 6.0. 

Tamer's presentation History of Drupal (aka from Drop 1.0 to Drupal 8.0) is available on SlideShare, and session videos of Tamer holding the presentation in London and Milan are available bellow.

History of Drupal SlideShare presentation

YouTube video of the session in London by DrupalCamp London

[embedded content]

YouTube video of the session in Milan by Associazione Drupal Italia - Available in HD

[embedded content]

Dec 09 2014
Dec 09

Use this step-by-step guide to create Mixpanel tracking events using Google Tag Manager.

Creating Google Tag Manager account

  • Signup to GTM using your Gmail account
  • On accounts page click "New account"
  • Set Account Name and Container Name
  • Click on "Web pages"  and +Add Domain and add your site
  • Accept GTM Terms of Service Agreement to continue
  • GTM will give you code to paste it on every page on your website. Place it immediately after the opening <body> tag.

Creating Mixpanel account

  • Signup to Mixpanel
  • Create new project to get their javascript library
  • Save that library to add it to your site using Google Tag Manager.
    (You can paste this snippet just before the </head> tag of your page, but we already added GTM code, so we can do it easily trough GTM tags)

Connecting Mixpanel with Google Tag manager

  • In GTM Container Draft click New -> Tag
  • Enter Tag Name e.g. "Mixpanel library" and select Tag Type "Custom HTML Tag"
  • Paste Mixpanel library in HTML textarea
  • Before saving tag, you will need to set firing rule. In section "Firing Rules" click +Add and select "all pages" and this tag will be on every page on your website.
  • Click save and publish container - you have successfully added Mixpanel to your site.

Creating tags to fire tracking events to Mixpanel

Click on Container Draft -> Overview and add new tag. In this example we are going to track every visit on home page

  • Set Tag Name "Home page" and Tag Type "Custom HTML Tag"
  • In HTML textarea open <script> tag and close it </script> add this code to push event to Mixpanel:
mixpanel.track("Homepage loaded");

Every time home page is loaded event "Homepage loaded" will be fired to Mixpanel

Adding mixpanel track properties to send it to Mixpanel

mixpanel.track('Homepage loaded', {
    'page name' : document.title,
    'url': location.href
});

This code will send event "Homepage loaded" with page name and page url. (You can add as many properties as you want to Mixpanel)

Setting firing rule

We have to set firing rule so GTM could recognize that we are on homepage.

  • In "Firing Rules" section click +Add to add new firing rule
  • Select option "Create new rule"
  • Enter rule name and you are ready to set "Conditions"
  • Set {{url path}} equals "/" and {{url hostname}} equals domain.com
  • If you have subdomains like sub.domain.com add condition: {{url hostname}} does not equal sub.domain.com
  • Click save and publish so this event can be tracked with Mixpanel.

Example firing event to Mixpanel when Blog page is loaded

  • Create tag and name it "Blog page loaded"
  • Select Tag Type "Custom HTML Tag" and write in <script></script> snippet:
mixpanel.track("Blog page loaded");

Send clicking events to Mixpanel using GTM click Listeners

Example sending link click listener to Mixpanel:

  • First thing you need to do is to create "Link Click Listener" that will listen every link clicked on your site
  • Add new tag and name it e.g. "Link Click Listener" and select Tag Type: Event Listener -> Link Click Listener
  • Set firing rule "all pages" so tag can listen link clicks on every page on your website
  • Save and publish

Now you need to create tag for specific link that will be clicked, for example we have this on our website:

<a href="https://ws.agency/log-in" class="loginClass" id="login">Login</a>
  • Create new tag and name it e.g. "Login link clicked"
  • Select Tag Type "Custom HTML tag" and add your code into <script> snippets, for example:
mixpanel.track("Login link clicked");
  • Now you need to set firing rule, click +Add and set rule name
  • Now in conditions choose {{event}} contains "gtm.linkClick" and {{element id}} equals "login"
  • If you want to use {{element class}} you will need to create macro and when you are done just replace {{element class}} equals e.g. loginClass
  • If element has more than one class, don't use "equals", use "contains"

Creating macro {{element class}} for targeting classes on website

  1. Click New -> Macro
  2. Set Macro Name and Macro Type to "Auto-Event Variable" and choose Variable Type "Element Classes"
  3. Click save to continue

Example sending button click listener to Mixpanel:

  • First thing you need to do is to create "Click Listener" that will listen every click on your site
  • Add new tag and name it e.g. "Click Listener" and select Tag Type: Event Listener -> Click Listener
  • Set firing rule "all pages" so tag can listen clicks on every page on your website
  • Save and publish

Now you need to create tag for specific button that will be clicked, for example:

<button class="registerClass" id="register">Register</button>
  • Create new tag and name it e.g. "Register button clicked"
  • Select Tag Type "Custom HTML tag" and add your code into <script> snippets, for example:
mixpanel.track("Register button clicked");
  • Now you need to set firing rule, click +Add and set rule name
  • Now in conditions choose {{event}} contains "gtm.Click" and {{element id}} equals "register"
  • If you want to use {{element class}} you will need to create macro (look above) and when you are done just replace {{element class}} equals e.g. registerClass
  • If element has more than one class, don't use "equals", use "contains"

There is one more way to send clicks to Mixpanel using jQuery:

  • Add new tag, set Tag Name and Tag Type to "Custom HTML Tag"
  • Set firing rule "all pages"
  • Write next code to HTML textarea (into <script> snippets):
jQuery( "#elementId" ).click(function() {
 mixpanel.track('element id clicked', {
    'url' : window.location.href
});
});

When you use mixpanel.track you can always add properties. Here we add pathname of url so we can see on what page was element clicked.

There is no need to use that because you have Auto Event Listeners in Google Tag Manager


Mixpanel event tracking using Google Tag Manager

When you finally understand Events in Mixpanel and GTM it's a piece of cake actually. If you have problem or error, leave us feedback - we are here to help.

Nov 14 2014
Nov 14

Twig is one such fairly new engine, built for PHP. An author of Twig claims that although PHP is considered a kind of template processor, over time the development of PHP itself, along with the options it gives, ventured away from what is expected from a modern template system. In comparison to using PHP itself as a template engine, Twig claims to offer improved conciseness, template oriented syntax, features, extensibility, documentation, security, error messages and speed.

Template Engines

Before venturing into Twig itself, let's cover a bit of theory. For theory on what a template engine or a template processor is, please refer to http://en.wikipedia.org/wiki/Template_processor

The definition from this article, based on consensus from primary literature:

A template processor (also known as a template engine or template parser) is a piece of software or a software component that is designed to combine one or more templates with a data model to produce one or more result documents.

There is also a separate but relevant article titled Web template systems which gives some additional illustrations and points and covers a general case of template engine use in the context of the Web.

About Twig

Twig is one such fairly new engine, built for PHP. An author of Twig claims that although PHP is considered a kind of template processor, over time the development of PHP itself, along with the options it gives, ventured away from what is expected from a modern template system. In comparison to using PHP itself as a template engine, Twig claims to offer improved conciseness, template oriented syntax, features, extensibility, documentation, security, error messages and speed.

Personally, my favorite is conciseness which is basically more streamlined and simplified syntax we can use by means of Twig when we output the content of data. This gives us the benefit of greater code readability and, as a consequence, maintainability.

The syntax of Twig is inspired by Jinja template languages and also looks very similar to Liquid template engine, a Ruby library.

Introductory How-to and Tutorial

Let's try Twig. I'll be following instructions on http://twig.sensiolabs.org/doc/intro.html#installation.

The version of PHP I have on a particular machine I'm using to review Twig is "5.5.3-1ubuntu2.6" which is high enough (minimal version of PHP needed is 5.2.4) so that I can immediately proceed to install Twig:

~/www$ mkdir twig_project
~/www$ cd twig_project
~/www/twig_project$ composer require "twig/twig:~1.0"
./composer.json has been updated
Loading composer repositories with package information
Updating dependencies (including require-dev)
  - Installing twig/twig (v1.16.2)
    Downloading: 100%         

Writing lock file
Generating autoload files

Let's create a test index.php file with the following contents copied from basic Basic API Usage section from http://twig.sensiolabs.org/doc/intro.html:

<?php

require_once '/path/to/vendor/autoload.php';

$loader = new Twig_Loader_Array(
    'index' => 'Hello {{ name }}!',
);
$twig = new Twig_Environment($loader);

echo $twig->render('index', array('name' => 'Fabien'));

Visiting index.php triggers "Parse error: syntax error, unexpected '=>' (T_DOUBLE_ARROW) in /[...]/www/twig_project/index.php on line 6".

Searching for others with the same error in this context doesn't yield any significant results on Google which leads me to conclude that this usage scenario doesn't work out of the box as such. The problem is a missing array keyword, which means we have to change the Twig_Loader_Array input parameter to "array( 'index' => 'Hello {{ name }}!' )" after which everything works OK and index.php outputs the following:

Hello Fabien! 

Let's explore the improvements of Twig compared to plain PHP further. To that end, I choose to follow http://twig.sensiolabs.org/doc/templates.html

We shall change our index.php to the following:

<?php

require_once 'vendor/autoload.php';

$loader = new Twig_Loader_Filesystem('./');
$twig = new Twig_Environment($loader, array(
    'cache' => false,
));

echo $twig->render('index.html', array('name' => 'Fabien'));

The cache is false by default, so there'll be no difference if we omit the whole configuration array as a second parameter. In high throughput production environment, you will probably need to enable, set and configure caching properly. Here is a useful blog post detailing issues that might be of concern at that point.

Also, we will add a template named index.html:

<!DOCTYPE html>
<html>
    <head>
        <title>My Webpage</title>
    </head>
    <body>
        <h1>My Webpage</h1>
        {{ name }}
    </body>
</html>

Now, when we run index.php in our browser, we get the following output:

<!DOCTYPE html>
<html>
    <head>
        <title>My Webpage</title>
    </head>
    <body>
        <h1>My Webpage</h1>
        Fabien
    </body>
</html>

At this point, the sky is the limit. :)

Let's update index.php again with a simulated article content, let's say we are building a minimalistic CMS or something similar and let's presume that we have loaded some array with all the related article content we need on some particular page:

<?php

require_once 'vendor/autoload.php';

$loader = new Twig_Loader_Filesystem('./');
$twig = new Twig_Environment($loader, array(
        'cache' => false,
));

echo $twig->render('index.html', array(
        "articles" => array (
                1 => array(
                        "title" => "Article 1 title",
                        "paragraphs" => array (
                                1 => "First paragraph.",
                                2 => "Second paragraph.",
                                3 => "Third paragraph."
                        ),
                        "footer" => array(
                                1 => "Date: ",
                                2 => "01/25/2014"
                        )
                ),
                2 => array(
                        "title" => "Article 2 title",
                        "paragraphs" => array (
                                1 => "First paragraph.",
                                2 => "Second paragraph.",
                                3 => "Third paragraph."
                        ),
                        "footer" => array(
                                1 => "Date: ",
                                2 => "02/25/2014"
                        )
                )
        )
));

We modify our template, index.html, to simply look like this:

<!DOCTYPE html>
<html>
<head>
    <title>Twig testing</title>
</head>
<body>
    <h1>Outputting multidimensional array</h1>
    {% for article in articles %}
        <article>
            <div class="title">{{ article.title }}</div>
            {% for paragraph in article.paragraphs %}
                <p>{{ paragraph }}</p>
            {% endfor %}
            <footer>
                {% for footer_element in article.footer %}
                    <span>{{ footer_element }}</span>
                {% endfor %}
            </footer>
        </article>
        <hr>
    {% endfor %}
</body>
</html>

Running it outputs the following:

<!DOCTYPE html>
<html>
<head>
    <title>Twig testing</title>
</head>
<body>
    <h1>Outputting multidimensional array</h1>
            <article>
            <h4>Article 1 title</h4>
                            <p>First paragraph.</p>
                            <p>Second paragraph.</p>
                            <p>Third paragraph.</p>
                        <footer>
                                    <span>Date: </span>
                                    <span>01/25/2014</span>
                            </footer>
        </article>
        <hr>
            <article>
            <h4>Article 2 title</h4>
                            <p>First paragraph.</p>
                            <p>Second paragraph.</p>
                            <p>Third paragraph.</p>
                        <footer>
                                    <span>Date: </span>
                                    <span>02/25/2014</span>
                            </footer>
        </article>
        <hr>
    </body>
</html>

This is all good, except for indentation inconsistencies between template and output. To compensate for them by manually deleting and inserting whitespaces in template itself would defeat the purpose of using a template engine to a certain extent, in terms of code readability. This particular issue probably has to do with whitespace artefacts coming out from Twig itself. The issue is present whether spaces or tabs are used. Perhaps playing with whitespace control might alleviate it, at the cost of more code in the template itself.

Conclusion

Indentation issues aside, heavy use of template engines in various SaaS systems like Shopify and Desk.com is contributing to their rising use and they are probably not going away any time soon. Templating as such has also found its use in CMS systems like Drupal 8. What these two scenarios have in common is the fact that you can give access to a template file to a person who only understands a little bit of HTML and still have him be able to modify system's HTML/CSS output in a practically safe manner, compared to letting hir mess with server side scripts or some obscure and delicate parts of CMS, perhaps with a theme layer of Drupal 7. This, along with many other features which templating provides, is what makes the usage of template engines such as Twig quite useful.

Oct 27 2014
Oct 27

There are three ways to add jQuery in Drupal - two ways are to include a .js file on some page, third way is writing JavaScript code inline.

Before we start writing jQuery, it's good to know that Drupal 7 includes jQuery 1.4.4 and jQuery UI 1.8.7. Drupal 6.0 to 6.2 included jQuery 1.2.3 while Drupal 6.3 includes an update to jQuery 1.2.6.

There are three ways to add jQuery to Drupal, two ways are to include a .js file on some page, and the third way is writing JavaScript code inline. When JavaScript is added to the page through Drupal, jQuery is automatically added to that page.

Add jQuery in Drupal 7 (drupal_add_js)

First way to add JavaScript code in Drupal 7 is to include a .js file using Drupal function drupal_add_js. In this example we are going to include websolutions.js file directly in our page:

<?php
drupal_add_js('websolutions.js');
?>

Another way to add JavaScript code in Drupal 7 is to include a .js file in theme's .info file using script tags. In this example we are going to include websolutions1.js, websolutions2.js and websolutions3.js files.

scripts[] = websolutions1.js
scripts[] = websolutions2.js
scripts[] = websolutions3.js

Now when the file is included, it will automatically be loaded on every page in the theme. Don't forget to clear the cache by clicking "Clear all caches" button located under Performance.

jQuery inline in Drupal 7

Adding parameter 'inline' allows us to write and execute a piece of JavaScript code directly on our theme's page, but we have to notice to use jQuery() instead of $() like in this example:

<?php
drupal_add_js('jQuery(document).ready(function () { alert("Welcome to websolutions.hr"); });', 'inline');
?>

Writing additional parameters in drupal_add_js function

  • 'external' parameter allows us to include external JavaScript file that is not hosted on local server
  • 'setting' parameter adds settings to Drupal global storage. All settings are going to be accessible via Drupal.settings behavior (find out more about Drupal behaviors below)

drupal_add_js options:

  • type ('file', 'inline', 'external', 'setting')
  • scope ('footer', ' header') - location where we want to place our script, in this case it can be footer or header
  • group - a number identifying the group in which to add the JavaScript. Available constants are: JS_LIBRARY, JS_DEFAULT and JS_THEME
  • every_page - this should be set to TRUE if the JavaScript is present on every page of the website for users for whom it is present at all
  • weight - a number defining the order in which the JavaScript is added to the page relative to other JavaScript with the same 'scope', 'group', and 'every_page' value
  • defer - if set to TRUE, the defer attribute is set within the <script> tag. Defaults to FALSE.
  • cache - if set to FALSE, the JavaScript file is loaded anew on every page call; in other words, it is not cached.
  • preprocess - if TRUE and JavaScript aggregation is enabled, the script file will be aggregated

drupal_add_js examples with parameters:

<?php
drupal_add_js('jQuery(document).ready(function () { alert("Welcome to websolutions.hr!"); });',
array('type' => 'inline', 'scope' => 'footer', 'weight' => 5) );
?>

Loading .js file from another server:

<?php
 drupal_add_js('http://example.com/websolutions.js', 'external');
?>

What are Drupal behaviors and how to use them

First thing to get clear - Drupal behaviors are not replacements for document.ready(), because Drupal behaviors can be run multiple times on any element on the page. This is an example of how to use them:

<?php
  drupal_add_js(array('moduleName' => array('key' => 'key_value')), 'setting');
?>

We defined module key value and now we can easily call it from JavaScript:

if (Drupal.settings.moduleName.key == key_value){
    alert('Welcome to websolutions.hr!');
  }

Why use Drupal.behaviors

  • Functionality to override JavaScript
  • Easy to reattach and attach behavior to specific context
  • HTML can be loaded via AHAH

About Drupal Sun

Drupal Sun is an Evolving Web project. It allows you to:

  • Do full-text search on all the articles in Drupal Planet (thanks to Apache Solr)
  • Facet based on tags, author, or feed
  • Flip through articles quickly (with j/k or arrow keys) to find what you're interested in
  • View the entire article text inline, or in the context of the site where it was created

See the blog post at Evolving Web

Evolving Web