Oct 05 2018
Oct 05

Our back-office management solution is now running on latest version of Drupal (8.6.1). An online demo is updated with the latest version that showcase the application features.

It has been a long run since the project was initiated while Drupal 8 was still under alpha stage. And there is still plenty of work to do.

An installation code is also available for those familiar with Drupal. The installation process is partially covered in this article.Thus if any of Drupalists are enthusiastic about business process solutions and would like to contribute, they are welcome.

We focus first on moving an old in-house php application into Drupal 8 modules. This covers many simple but useful back office functionalities like address book, products and services database, sales documents (invoices, purchases), projects, HR, logistics documents, cost tracking, journal records and others collaborative tools. It is still a young project that will certainly need more integration provided by Drupal 8 capabilities as it grows. Some of Drupal 8 features like multilingual support and tour guide are already very useful in the business environment we operate.

The solution is run by small businesses and start-ups. We provide paid support, comprehensive cloud solution and management expertise service as well. It is a very good solution for small business that need to organize their back office and data management.

On one hand, it gives us tremendous information and feedback about all necessary improvements we need to implement  and fixes to apply. On the other hand, Drupal 8 has proven to be very stable and efficient in running this solution, and we are still to explore plenty of value added features that are of high value in data processing like RESTful Web Services for instance or plugins developments

We encourage anyone to explore this solution, provide feedback, and even contribute to the project.

Sep 21 2018
DA
Sep 21

In this post we will share our experience in installing a Drupal 8 application on an Amazon EC2 server with latest Ubuntu 18.04 LTS.

Installing Drupal with composer greatly simplify system maintenance and further update.

AWS image

First you will need to create EC2 instance with proper AMI from Amazon Web Service. You can find the AMI from the locator.

We will not cover in detail this part, as we assume this is already covered by many other blogs and tutorials.

We pick latest 18.04 LTS version of Ubuntu to comply with requirements of Drupal 8 with PHP 7.2.

Composer

Once your server is running, the next step is to install composer.

Once again, we will not go too much into details as composer installation is also widely covered.

In our case we followed similar procedure as the one described here.

Drupal

For actual installatin of Drupal with composer, there is a guide at drupal.org with 3 options. We picked the option A.

The repository is a composer template for Drupal projects with pretty good usage guide. The latest version will install Drupal 8.6.1.

We run the command:

git clone https://github.com/drupal-composer/drupal-project.git <MyAppName>

(note: the code will be copied within the folder "MyAppName" within your current folder location. For instance if you are in /var/www, the application will be in /var/www/MyAppName)

At this point we edited the composer.json file to match our desired folder configuration. You need to edit manually the installer path here before installing the application or if you prefer, keep the default paths.

"installer-paths": {
            "web/core": ["type:drupal-core"],
            "web/libraries/{$name}": ["type:drupal-library"],
            "web/modules/contrib/{$name}": ["type:drupal-module"],
            "web/profiles/contrib/{$name}": ["type:drupal-profile"],
            "web/themes/contrib/{$name}": ["type:drupal-theme"],
            "drush/Commands/{$name}": ["type:drupal-drush"]
        },

To edit, run command:

Sudo nano MyAppName/composer.json

and edit "installer-paths". In our case we changed to:

Once your have the desired folder configuration, you can run actual installation command:

composer -vvv install

(note: -vvv option is optional)

This will install Drupal site.

Custom application

One of the purpose of using composer installation is to merge other composer files and install custom plugins and applications.

To be able to merge composer files, you need to install the composer-merge-plugin first with command:

composer require wikimedia/composer-merge-plugin

then run:

composer update --lock

You can now add additional plugins with their specific composer installer. In our case, we install our own application EK Management tools suite with the following command:

composer require arreasystem/ek:"dev-8.x-dev"

This will install custom application.

You can merge composer.json paths as "extra" option in main composer.json:

Sudo nano MyAppName/composer.json

For instance add the custom plugins paths:    

"extra": {
          "merge-plugin": {
                      "include": [
                          "modules/contrib/ek/ek_admin/composer.json"
                      ],
                      "recurse": true,
                      "replace": false,
                      "merge-extra": false
                  },

And run:

composer update --lock

This will for instance install following libraries:

You may encounter error with composer when updating with an out of memory error. This will happen with low specification EC2 instances. To solve this problem, add swap memory on Ubuntu server.

Create swap file: sudo dd if=/dev/zero of=/swapfile bs=2M count=2048 (this will create a 4M memory swap);

Enable file: sudo chmod 600 /swapfile;

Allocate: sudo mkswap /swapfile;

Start: sudo swapon /swapfile.

With this installation, you will just have to run composer update to update your installation version. This comes also with Drush and Drupal Console installed. Don't forget to run update.php after core update if necessary.

We hope this short post has been useful. Feel free to add comment or questions.

Jul 25 2017
Jul 25

This is an example of anti-virus implementation with an Ubuntu server.

Our back office management solution allows users to upload files in various sections of the application for storage or file sharing. For this reason, checking of files for virus is an important advantage.

We use the ClamAV module integration from Drupal 8.

1) Install ClamAV on Ubuntu

Installation on Ubuntu server is straight forward.  However, it is better to install with clamav-daemon clamav-freshclam options for later settings

You can test with clamscan -r /home for instance

For further options you may refer to ClamAV website.

2) Install and set-up Drupal module

Module installation on Drupal 8 has no specific requirements.

As indicated on the module page, "Daemon mode" is preferred when executing the scan.

In the settings page (/admin/config/media/clamav), select Daemon mode (over Unix socket) in scan mechanism

You need to indicate the path for the socket pointing file; it can be found in the configuration file  : /etc/clamav/clamd.conf.

Input the file path into next setting:

3) Test

When uploading a file on the server via any upload interface, the file is scanned and validated. Scanning process is logged:

The Eicar test virus file is filtered when uploaded:

If you have implemented ClamAV with Drupal and have further comments, please feel free input your own.

Thank you.

Jan 15 2017
Jan 15
Custom view

This video tutorial was made for our customer in order to demonstrate how to build a custom view to extract the data they need.

Here we are building a view to extract sales data per project where each project is classified into a category. We link invoice table with project table and add filter to be able to view data by year and category of project.

The tables and data sources used in this view are custom tables from our back office management solution built on Drupal 8. However, the principles of building a view are applicable to any other data source and this tutorial can be used to learn simple view building with tables relationships and filter.

Your browser doesn't support HTML5 video tag.

Jan 06 2017
JK
Jan 06

In this article, we will present how we built a simple twitter feed in Drupal 8 with custom block and without any custom module. This block will display a list of tweets pulled from a custom list as in the example shown in the side bar.

As a prerequisite, you need to have a twitter account and a custom list in your feeds.

1) Get code from "twitter publish"

First, we need to get the code that is generated by twitter publishing api.

On this page, enter the needed url for the list of tweets you want to display as in the example below:

embed

The code will be generated from the url and you can add some custom format:

format

Once you have updated your options, you can just coy the custom code:

code

We will use this code to create the block.

2) Custom block

In custom block library (/admin/structure/block/block-content), click on "+ Add custom block" button.

In the custom block body (full HTML), copy the code generated previously in "<> source" mode and click save:

edit block

You now have a custom block that you can place anywhere in your site. To do so go to /admin/structure/block and click on the "Place block" button where you want to display your block.

The result :

Twitter block

For more advance block creation, see also this article.

Dec 08 2016
JK
Dec 08

This script will help display the results of a search by keyword instantly via an ajax call. It can be applied to various search types. From user point of view it creates a good user experience and efficient working flow.

For instance we apply this search in products and services module and payroll module to quickly find the data to be reviewed or edited.

 

1) create the form

The search form is very simple and is made from a text field and <div> to display the results. We do not need to "submit" the form as the search results are returned by an ajax call.

/**
 * @file
 * Contains \Drupal\MyModule\Form\SearchProductsForm.
 */

namespace Drupal\MyModule\Form;

use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;

/**
 * Provides a form to search items
 */
class SearchProductsForm extends FormBase {

  /**
   * {@inheritdoc}
   */
  public function getFormId() {
    return 'MyModule_products_search';
  }

  /**
   * {@inheritdoc}
   */
  public function buildForm(array $form, FormStateInterface $form_state) {

          $form['name'] = array(
              '#type' => 'textfield',
              '#id' => t('product-search-form'),
              '#size' => 50,
              '#attributes' => array('placeholder'=>t('Enter item code, barcode or name')),
              '#attached' => ['library' => array('MyModule/MyModule.autocomplete')],
            );
          
          $form['list_items'] = array(
            '#type' => 'item',
            '#markup' => "<div id='product-search-result'></div>",
          );   
        
    return $form;  

  }

The form is located in mymodule/src/From.

You can notice that the library MyModule.autocomplete is attached to the search box. Let's create the library script now.

2) Library and autocomplete script

The library is declared in mymodule.libraries.yml file at the root of our custom module:

MyModule.autocomplete:
  version: 1
  css:
    theme:
      css/MyModule.css: {}
  js:
    js/MyModule_autocomplete.js: {}
  dependencies:
    - core/jquery
    - core/drupal

The main reference in the library is MyModule_autocomplete.js file that manage the ajax call and display of results. This file will contain the following script:

(function ($, Drupal, drupalSettings) {

  Drupal.behaviors.ek_products_autocomplete = {
    attach: function (context, settings) {

      jQuery('#product-search-form').keyup(function() {
      
        var term = jQuery('#product-search-form').val();

        jQuery.ajax({
          dataType: "json",
          url: drupalSettings.path.baseUrl + "autocomplete_ajax" ,
          data: { option: "image", q: term },
          success: function (data) {
              var content = '';
              var i = 0;
              for(;data[i];) {
                  
                  var editUrl = "<a class='product_image-link' href='" + drupalSettings.path.baseUrl
                          + "item/" + data[i]['id'] + "'>" + data[i]['picture'] + "</a>";                  
                  content += "<p>" + editUrl + "  " + data[i]['name'] + "</p>";
                  i++;
              }
              
              jQuery('#product-search-result').html(content);

          }
          });      
      });   
    }
  };
})(jQuery, Drupal, drupalSettings);

Few comments here.

First the URL is pointing to "autocomplete_ajax"; this route must exist in your routing file (i.e mymodule.routing.yml):

MyModule_autocomplete_ajax:
  path: '/autocomplete_ajax'
  defaults:
    _controller: '\Drupal\mymodule\Controller\ProductsController::autocomplete'
  requirements:
    _permission: 'view_products'

We include some class properties that will be defined in a file MyModule.css included in the library declaration above.

The result is returned as a json format from the controller but will be displayed as html format in the browser.

3) the Controller

The controller function (i.e autocomplete()) will query the database and return the results. In the simplified version below, we do not describe the actual database query that may vary from structure to structure. The result returned is pretty simple and in our case we return 3 elements when 'image' option is declared: picture, name (made of different information) and id.

public function autocomplete(Request $request) {

        $term = $request->query->get('q');
        $option = $request->query->get('option');

        /*
        * do the DB query here filtered by $term: $data
        */
        
        $return = array();
        while ($result = $data->fetchObject()) {

            if (strlen($result->description) > 30) {
                $desc = substr($result->description, 0, 30) . "...";
            } else {
                $desc = $result->description;
            }
            
            if($option == 'image') {
                $line = [];
                if ($result->uri) {
                         $pic = "<img class='product_thumbnail' src='"
                        . file_create_url($result->uri) . "'>";
                    } else {
                        $pic = '[]';
                    }
                    $line['picture'] = isset($pic) ? $pic : '';
                    $line['name'] = $result->id . " " . $result->itemcode . " " . $result->barcode . " " . $desc . " " .$result->supplier_code;
                    $line['id'] = $result->id;
                    
                    $return[] = $line;
                
            } else {
                $return[] = $result->id . " " . $result->itemcode . " " . $result->barcode . " " . $desc . " " .$result->supplier_code;
            }
           
        }
        return new JsonResponse($return);
}

4) Display

You can now create the routing to display your form and see the result in your browser. To call your search form, simply create a route to the form as in the example below (mymodule.routing.yml):

MyModule.searchForm:
  path: '/mysearchform'
  defaults:
    _form: '\Drupal\mymodule\Form\SearchProductsForm'
  requirements:
    _access: 'TRUE'

Display can be customized within the css file. In our case, we define  "thumbnail"  properties to display the items images:

/* search display */
.product_thumbnail {
    
    width: 40px;
    height: 40px;
    overflow: hidden;
    vertical-align: middle;
    margin-right: 10px;
    border: solid 1px;
    -webkit-border-radius: 5px;
    -moz-border-radius: 5px;
}

search

Feel free to add your comments or own experience.

Thank you.

Jun 26 2016
JK
Jun 26

In this article, we will see how we built custom blocks in EK management tools suite with a sample basic block in a module called 'mymodule' used for demo. It can be used to display multiple content, static or dynamic as in the example above.

Create the block script

First we will create a script that will display some content within a block. the script file will be called MyBlock.php and is placed in /mymodule/src/Plugin/Block/.


/**
 * @file
 * Contains \Drupal\mymodule\Plugin\Block\MyBlock.
 */
namespace Drupal\mymodule\Plugin\Block;
use Drupal\Core\Block\BlockBase;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Access\AccessResult;

/**
 * Provides a 'Custom module widget' .
 *
 * @Block(
 *   id = "my_block",
 *   admin_label = @Translation("My custom block"),
 *   category = @Translation("mymodule Widgets")
 * )
 */

The file header will contain the namespace of the file, the dependencies and most important, the annotations that define the block for discovery (more information about this in Drupal).

For the purpose of this demo, the content of the block will be very simple:

class MyBlock extends BlockBase {
  /**
   * {@inheritdoc}
   */
  public function build() {
 
  $items = array();
  $items['title'] = t('Custom block');
  $items['content'] = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. "
          . "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. "
          . "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. "
          . "Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.";
 
 
  return array(
    '#items' => $items,
    '#theme' => 'mymodule_block',
    '#attached' => array(
      'library' => array('mymodule/style'),
      ),
    );
 
  }
  /**
   * {@inheritdoc}
   */
  protected function blockAccess(AccountInterface $account) {
    if (!$account->isAnonymous() ) {
      return AccessResult::allowed()->addCacheContexts(['route.name']);
    }
    return AccessResult::forbidden(); 
  }
}

We set a title and content text to be displayed in the block in the build() function. This block use a theme template called "mymodule_block" and a library with custom css style. We will not cover this part here. The blockAccess()  function control the visibility of the block and restrict it to authenticated accounts.

Display block

In order to demonstrate the display of the block, we created an empty page with our /mymodule/block in mymodule.routing.yml, however, the block can be displayed in any page or region.

mymodule.block:
  path: '/mymodule/block'
  defaults:
    _controller: '\Drupal\mymodule\Controller\MyModuleController::BlockPage'
  requirements:
    _access: 'TRUE'

BlockPage() will just return an empty array in this sample module.

/**
 * @file
 * Contains \Drupal\mymodule\Controller\MyModuleController.
 */

namespace Drupal\mymodule\Controller;

use Drupal\Core\Controller\ControllerBase;


class MyModuleController extends ControllerBase {

    public function BlockPage() {
        return array();   
    }

}

Now we can navigate to the block layout management page of Drupal 8, /admin/structure/block, and install our custom block.

We place the block in "Content" of the page and click on the button to select the custom block we created:

We click on "Place block" for the selected block and configure the block to show on our custom page:

After saving, we can now navigate to our page /mymodule/block and see the block in action:

Block configuration file

The block can be set in yml configuration file in order to be installed with the module. In order to do that, simply go to /admin/config/development/configuration/single/export to export the block configuration that we just activated:

Copy the configuration script into a file called block.block.mycustomblock.yml and place it under /mymodule/config/install; the block will be activated at installation time.

We hope this block example is useful and feel free to add your comments or suggestion.

May 08 2016
May 08

Hello,

Our back-office management solution is now running on version Drupal 8.2.3. The live demo is updated with the latest version.

It has been a long run since the project was initiated while Drupal 8 was still under alpha stage. And there is still plenty of work to do.

One objective is to make a full distribution package including most of the current functionalities available in the demo version. Our main issue with this target is the lack of resources and time. Thus if any of Drupalists are enthusiastic about business process solutions and would like to contribute, they are welcome.

We focus first on moving an old in-house php application into Drupal 8 modules. This covers many simple but useful back office functionalities like address book, products and services database, sales documents (invoices, purchases), projects, HR, logistics documents, cost tracking, journal records and others collaborative tools. Thus it is still a very young project that will certainly need better integration with Drupal 8 capabilities as it grows. Some of Drupal 8 features like multilingual support and tour guide are already very useful in the business environment we operate.

The solution is run by small businesses and start-ups. We provide paid support, comprehensive cloud solution and management expertise service as well. On one hand, it gives us tremendous information and feedback about all necessary improvements we need to implement  and fixes to apply. On the other hand, Drupal 8 has proven to be very stable and efficient in running this solution, and we are still to explore plenty of value added features that are of high value in data processing like RESTful Web Services for instance or plugins developments

We encourage anyone to explore this solution, provide feedback, and even contribute to the project.

Feb 21 2016
JK
Feb 21

In previous article we explained how we installed the Swift Mailer module and its dependencies.

In this second part, let's see how we configure and implement it to use in our modules to send formated HTML mail with attachment.

First you will need to have Mail system module installed already. There is no particular issue or difficulty here.

1) Configure Swift Mailer

In Swift Mailer configuration (/admin/config/swiftmailer/transport) , we select the following options:

Transport:

Messages:

2) Custom module with email attachment

In our ERP application, we have a function that handles sharing of stored or online generated document via email.

In this function "mail_attachment()", we have an option to use Swift Mailer when available as module to handle the attachment.

Basically, the function handles the parameters received and prepare them for sending using a twig template.

The parameters used are: a list of email addreses, the uri of the file to attach, a text message and some option data (I.e. site name or logo). The part that is important here is the attachment preparation:

            $finfo = finfo_open(FILEINFO_MIME_TYPE);
            $attachments = new stdClass();
            $attachments->uri = '/path/to/file';
            $attachments->filename = 'file name';
            $attachments->filemime = finfo_file($finfo, $file);
            $params['files'][] = $attachments;

Once we have compiled and formatted all information necessary for our email, we call the Drupal service to send our message with attachment:

            Drupal::service('plugin.manager.mail')->mail(
                        'ek_admin',
                        'attachment',
                        [email protected],
                        \Drupal::languageManager()->getDefaultLanguage(),
                        $params,
                        $currentuserMail,
                        TRUE
             );

In this piece of code we notice:

  • The name of the module used ('ek_admin');
  • The name of the key to identify the mail template used ('attachment').

In the above referred module, we have a hook_mail() function that handle the message based on the selected key:

          function ek_admin_mail($key, &$message, $params) {
              switch($key) {
                  case 'attachment':
                  $message['subject'] = $params['subject'];
                  $message['body'][] = $params['body'];
                  $message['options'] = $params['options'];
                  $message['files'][] = $params['files'];
        
                  break;
              }
          }

The above parameters are important to build the message template.

3) The twig template

The template name format will follow this structure: swiftmailer--[module name]--[key].html.twig

We the create the file swiftmailer--ek_admin--attachment.html following the obove parameters that we used in our module, being the module name and the key.

This twig template can be designed as required with normal html tags to be sent as html mail. Here is a simplified example:

          <table width="800px" cellpadding="0" cellspacing="0">
                  <tbody>
                      <tr>
                         <td style='font-size:1.2em;padding:20px;vertical-align: bottom;'>{{ message.options.site }}</td>
                         <td ><img class="img-responsive" style="width:130px;float:right;" src="https://arrea-systems.com/Install_use_SwiftMailer_Drupal_8_%28part_2_imp...{{ message.options.logo }}" />
                         </td>
                     </tr>
                </tbody>
         </table>
         <hr/>
          <p>{{ 'Document'|t }} : {{ message.options.filename }}</p>
          <p>{{ 'Document size'|t }} : {{ message.options.size }}</p>

Important: the trick here here is that the template has to be copied in the template folder of the theme used which may be a current limitation to the module. In our example, we use bartik theme:


4) Configure Mail System

In the Mail System configuration (), we select Swift Mailer as default mail handling system and keep the theme as current:

Now in custom configurations, we tell Mail Systems about our module and key described above and to use Swift Mailer when they are called:

After saving the configuration, we have our module and key registered:

5) Send a file

For simple example, we will take a file from a project page and email it to a user

And the result is the email received as follow, according to our Swift Mailer template:

Feel free to add your comments or own experience with Html email with Drupal 8.

Thank you.

Feb 14 2016
JK
Feb 14

In a previous post from 2015, we described usage of Swift Mailer module to send HTML mail and mail with attachment. At this time, the module was not yet available for Drupal 8.

There is now a version alpha1 available. Let's go through installation process.

Because it has been rather tedious for us, we will try to explain the flow of the process as much as possible to help you save time.

1) Composer

The prerequisite is the installation of composer.

In our case we installed first on Windows inside a folder named  F:\Program Files\composer2\.

The installation exe for Windows can be found here.

However, it did not work in our case and we needed to install it manually with the below command (see more details here):

 

php -c C:\windows\php.ini -r "eval('?>'.file_get_contents('https://getcomposer.org/installer'));"

Once installed you will get the confirmation message:

Installation on Ubuntu server was very easy (see how):

2) Composer manager install and init

The Drupal 8 version of this module is deprecated and no longer needed, due to improvements in Drupal 8.1. Use Composer directly to get the needed modules, which will also download their required libraries.

If you try to install the Swift Mailer module now, without dependencies, you will get the following error message:

Thus we will use composer manager to install the library as suggested by the module.

Our version of composer manager is 8.x-1.0-rc1+0-dev. After installing the module (/admin/modules), the status report (/admin/reports/status) indicates that the module has to be initialized:

 

Composer Manager

Not initialized Run the module's init.php script on the command line

 

Init.php is in Drupal_path/modules/composer_manager/scripts/. We use the following command in Windows to do the initialization as required:

 

\Drupal path\modules\composer_manager\scripts\php init.php

 

Then we get the confirmation as follow:

 

For Ubuntu, the process is similar:

 


What happened in practice is that the "composer.json" file located in Drupal root has been updated as in the example below:

 

autoload": {
"psr-4": {
"Drupal\\Core\\Composer\\": "core/lib/Drupal/Core/Composer",
"Drupal\\composer_manager\\Composer\\": "F:\\path to module\\composer_manager/src/Composer"
}
},
"scripts": {
"pre-autoload-dump": "Drupal\\Core\\Composer\\Composer::preAutoloadDump",
"post-autoload-dump": "Drupal\\Core\\Composer\\Composer::ensureHtaccess",
"post-package-install": "Drupal\\Core\\Composer\\Composer::vendorTestCodeCleanup",
"post-package-update": "Drupal\\Core\\Composer\\Composer::vendorTestCodeCleanup",
"drupal-rebuild": "Drupal\\composer_manager\\Composer\\Command::rebuild",
"drupal-update": "Drupal\\composer_manager\\Composer\\Command::update"
}

 

 

Going back to the status report now indicates that we can run drupal-update to update modules dependencies via composer:

Composer Manager

Composer update needed. Run composer drupal-update on the command line to update dependencies.

Composer-manager has its own report as well with the following indications:


3) Update dependencies

From windows command line, run "composer drupal-update" from within the Drupal installation root:

If you get an error, you can also run the update by pointing to the composer file as per example below:

The libraries will be updated accordingly and specifically those needed by Swift Mailer:

You can check that in the folder \Drupal\vendor the swiftmailer and html2text folder are now present.

For Ubuntu, the process is the same for dependencies update:

4) Install Swift Mailer

Ok, now you can try again to install Swift Mailer module.

At this point, you may still have the error above about missing library which is weird since we just updated it.

Actually, it may be possible that composer did not update the autoloader properly (bug?). You may solve this by running the command

or

After this you should get the expected result:

We hope you can benefit from this experience.

Feel free to leave a comment or question if you have any.

On a next post we will see how we use Swift Mailer to attach documents to mail.

Feb 07 2016
JK
Feb 07

In Drupal 8 there is a Tour module in core that is very useful when it comes to web applications. In EK management tools we target professional users with small to medium scale companies. They usually have limited resources and time to spend on back office trainings. This is where the Tour module is very convenient to introduce functionalities to users who can quickly grasp the functions available to manage their back office.

We use the Tour functionality in our pages to guide users in their daily tasks like for instance in the form to create a new invoice or project page:

Invoice guided tourproject tour

Implementation is actually very simple and results are great from the user experience point of view.

To achieve the above result, simply create a configuration file called tour.tour.invoice.yml. In this file add the properties information:

id: ek_sales.invoice_create
module: ek_sales
label: 'Create invoice tour'
langcode: en
routes:
  - route_name: ek_sales.invoices.create

Those properties are straightforward. The Id must be unique. In "routes", indicate where the Tour will be placed and route_name is referred in the module routing.yml file.

Then comes the tips definitions that will display the actual help text (we just present here the first 3 items as an example):

tips:
  introduction:
    id: introduction
    plugin: text
    label: 'Create or Edit invoice guide'
    body: 'This is an guided tour to create and edit an invoice.'
    weight: 1
  first-item:
    id: first-item
    plugin: text
    label: 'Invoice header'
    body: 'You must first select the header for you invoice. Header is defined by a company name. You can select any company to which you have access to. Selecting a company will in turn define other parameters like available bank accounts or credit accounts.'
    weight: 2
    location: bottom
    attributes:
      data-id: edit-head
  second-item:
    id: second-item
    plugin: text
    label: 'Allocation'
    body: 'In a multi companies configuration type, you can allocate the invoice to a different company of the group. This is used when a company invoice on behalf of another and you want to keep track of internal transactions.'
    weight: 3
    location: top
    attributes:
      data-id: edit-allocation

Again tips properties are also straightforward.

Id is unique. The 'text' plugin is the default display type in core. "Label" will appear as a box title and "body" is the extended description or help text. The 'weight' defines the sequence of tips display. the "attributes" link the tip to a specific element on the page that can be defined by its id (data-id) like in the cases above or by its class (data-class). And finally you can define the "location" of the tip around the element (top, bottom, left, right).

When you navigate to the page where the Tour has been configured, you can now see a Tour button which will start the Tour display:

tour button

Feel free to add your comments or suggestion.

Jan 31 2016
JK
Jan 31

In previous articles (here and here), we have seen a method to add custom views and data in MyModule.

With Drupal 8 there is a very easy and practical way to add this custom view as a configuration that will be installed with the module.

1) extract the configuration data

Navigate to "/admin/config/development/configuration/single/export".

On this page, select configuration type 'view' and configuration name 'My module list' that was created earlier.

Single export

2) create configuration install file

You will obtain from the above export a list of configuration data that you can copy and paste into a file called for instance "views.view.mymodule-list.yml";

Simply place this file into the install folder :

Install folder

Upon installation of the module, the view will be automatically created.

We hope this demonstration is helpful to you. You can view as well another demo in our custom module address book , part of EK management tools that use the same technique.

If you have comments or want to add techniques to improve views of custom data, feel free to do so.

Jan 24 2016
JK
Jan 24

In previous article we have seen how to declare the data accessible in a custom view in MyModule.

Now that the data from our tables mymodule_tb (and mymodule_tb_2) are available, let's create the list view.

First navigate to "/admin/structure/views/add" and create the view by entering basic information as per the example below.

New view basic info

After "save" you are redirected to "Edit" form where further settings will be set.

1) Add fields from you source table

Add fields

From this form, select the fields to display. We will select 2 here "name" and "type" (refer to the table structure declared in MyModule_views_data())

Add fields

Once added, the default preview is as follow:

Preview 1

You can see that the "type" field value is made of numbers which should be displayed as color type instead in our example.

2) Rewrite results for "type" field

To display information that is more practical for a user, we will convert the "type" filed values (1,2,30) into colors names.

To do that click on the field to edit it:

Edit field settings

In the edition form, go to "REWRITE RESULTS" section and click on "Override the output of this field with custom text":

Rewrite rule

In this Text box, we set rewrite rule based on type value as this : 1 = 'Bleu', 2 = 'Green', 3 = 'Red'.

For this rules we used the Twig script as it is suggested by the form.

After saving this rule, we can see that our list display will now output color names instead of "type" field values:

Preview 2

3) Add custom filter

To add a filter, click on the 'Add' button:

Add filter

In the form, select the filed 'name' as the filter criteria:

filter criteria

Then select "Expose this filter to visitors"  and complete the settings as per example below (we selected condition "starts with" and left the value empty):

Filter criterion

After applying the filter settings, the list view is completed.

You van now navigate to the link set in the view "/my-module-list" and see the result:

View result

We hope this demonstration is helpful to you. You can view as well another demo in our custom module address book part of EK management tools.

If you have comments or want to add techniques to display views of custom data, feel free to do so.

In the next article we will see how to add this view as a configuration in our custom module.

Jan 17 2016
JK
Jan 17

In our EK management tools suite we have custom designed lists of items like for instance list of management documents.

Those lists are build with custom codes and templates which is somehow more convenient to manage with complex data, links, menus and filters as in the example below.

Example of documents list

However for simple list, the views module is very useful and can be integrated in a custom module as well to automatically create the list.

Here is an example with companies list in the system address book module showing the company name as link and a field about the type of record plus a simple filter box.

List companies

To achieve this, you need first to reference the data into your module called for instance MyModule.

The sample table structure containing the data is as follow:

    `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
    `name` VARCHAR(100) NOT NULL DEFAULT '',
    `type` VARCHAR(5) NULL DEFAULT NULL,
    PRIMARY KEY (`id`)

The field `type` in our case is a numeral from 1 to 3 that maps to defined description (Here we will use 1 => blue, 2 => green and 3 => red).


In MyModule.module file in our custom module, we reference those data to be accessible in views with MyModule_views_data() function.

In this function, we declare the following information:

/**
 * @file
 * MyModule module .
 */

function MyModule_views_data() {
  // This write hook_views_data() for the main table

  // First, the entry $data['mymodule_tb']['table'] describes properties of
  // the actual table – not its content.

  $data['mymodule_tb']['table']['group'] = t('My Module');

  // Define this as a base table
  $data['mymodule_tb']['table']['base'] = array(
    'field' => 'id', // This is the identifier field for the view.
    'title' => t('My Module'),
    'help' => t('My Module contains some data.'),
    'database' => 'external_db',
    'weight' => -10,
  );

  // This table references the {_tb_2} table. The declaration below creates an
  // 'implicit' relationship to the _tb_2 table
  $data['mymodule_tb']['table']['join'] = array(
    'database' => 'external_db',
    'mymodule_tb_2' => array(
      'left_field' => 'mid',
      'field' => 'id',
      'database' => 'external_db',
    ),
  );

  // Next, describe each of the individual fields in this table to Views.
  //  ID table field.
  $data['mymodule_tb']['id'] = array(
    'title' => t('mymodule_tb id'),
    'help' => t('mymodule_tb id.'),
    'relationship' => array(
      'base' => 'mymodule_tb_2', // The name of the table to join with
      'field' => 'mid', // The name of the field to join with
      'id' => 'standard',
      'label' => t('linked table to mymodule_tb'),
    ),
        'field' => array(
      'id' => 'numeric',
    ),
    'sort' => array(
      'id' => 'standard',
    ),
    'filter' => array(
      'id' => 'numeric',
    ),
  );

  // Example plain text field.
  $data['mymodule_tb']['name'] = array(
    'title' => t('name'),
    'help' => t('entry name.'),
    'field' => array(
      'id' => 'standard',
    ),
    'sort' => array(
      'id' => 'standard',
    ),
    'filter' => array(
      'id' => 'string',
    ),
    'argument' => array(
      'id' => 'string',
    ),
  );
 
  $data['mymodule_tb']['type'] = array(
    'title' => t('type'),
    'help' => t('type: 1 blue, 2 green, 3 red'),
    'field' => array(
      'id' => 'numeric',
    ),
    'sort' => array(
      'id' => 'standard',
    ),
    'filter' => array(
      'id' => 'numeric',
    ),
  );   
      

// This write hook_views_data() for the linked table

  $data['mymodule_tb_2']['table']['group'] = t('My Module table 2');

  $data['mymodule_tb_2']['table']['base'] = array(
    'field' => 'id', // This is the identifier field for the view.
    'title' => t('My Module table 2'),
    'help' => t('My Module tb_2 contains linked data to mymodule_tb.'),
    'weight' => -10,
    'database' => 'external_db',
  );

  $data['mymodule_tb_2']['table']['join'] = array(
    'mymodule_tb' => array(
      'left_field' => 'id',
      'field' => 'mid',
      'database' => 'external_db',
    ),
  );

  //  ID table field.
  $data['mymodule_tb_2']['id'] = array(
    'title' => t('tb_2 id'),
    'help' => t('tb_2 id.'),
       'field' => array(
       'id' => 'numeric',
    ),
       'sort' => array(
       'id' => 'standard',
    ),
       'filter' => array(
       'id' => 'numeric',
    ),
  );
 
  $data['mymodule_tb_2']['mid'] = array(
    'title' => t('mymodule_tb id'),
    'help' => t('mymodule_tb id ref.'),
    'relationship' => array(
      'base' => 'mymodule_tb',
      'field' => 'id',
      'id' => 'standard',
      'label' => t('mymodule_tb entry'),
    ),
       'field' => array(
       'id' => 'numeric',
    ),
       'sort' => array(
       'id' => 'standard',
    ),
       'filter' => array(
       'id' => 'numeric',
    ),
  );


  $data['mymodule_tb_2']['comment'] = array(
    'title' => t('comment'),
    'help' => t('linked comment.'),
    'field' => array(
      'id' => 'standard',
    ),
    'sort' => array(
      'id' => 'standard',
    ),
    'filter' => array(
      'id' => 'string',
    ),
    'argument' => array(
      'id' => 'string',
    ),
  );
  return $data;
}

Few remarks about the above information:

  • In this example, we have a table linked to our main table which is described by $data['mymodule_tb']['table']['join'] and  $data['mymodule_tb_2']['table']['join']
  • The dabase containing the data is specified as 'external_db'. In our configuration, we do not use the default database of Drupal installation (this database must be defined in settings.php).

If we navigate to "/admin/structure/views/add", we can now create a view based on our main table content:

My Module view

In the next article we will describe how to create the page similar to our address book list view with specific rewrite results for "type" field and filter criterion.

Feel free to add your own comments or suggestions.

Jan 10 2016
JK
Jan 10

In this article we will describe how we created the calendar in our back-office management application EK (see demo).

Calendar

For this function, we used the FullCalendar plugin and its dependencies.

1) Create the Drupal 8 library

In a file called MyModule.libraries.yml, insert the following css and js configurations:

MyModule.calendar:
  version: VERSION
  css:
    theme:
      css/ek_calendar.css: {}
      js/cal/fullcalendar/fullcalendar.css: {}
      js/jquery.qtip/jquery.qtip.css: {}
  js:
    js/cal/fullcalendar/lib/moment.min.js: {}
    js/cal/fullcalendar/fullcalendar.js: {}
    js/jquery.qtip/jquery.qtip.min.js: {}
    js/cal/calendar_script.js: {}
  dependencies:
    - core/jquery
    - core/drupal
    - core/drupalSettings
    - core/drupal.ajax
    - core/drupal.dialog
    - core/jquery.ui.datepicker

Note: we also used jQuery qtip to display title pop-up in the calendar, but this not an obligation.

The file calendar_script.js is for custom javascript scripts needed in this implementation.

2) create the routes

In MyModule.routing.yml we will need 2 routes. The first one is the main route to the calendar display function in our countroller; the second one is to pull data displayed in the calendar from an ajax call.

MyModule_calendar:
  path: '/path/calendar'
  defaults:
    _title: 'Calendar'
    _controller: '\Drupal\MyModule\Controller\CalendarController::calendar'
  requirements:
    _permission: 'calendar'

MyModule_calendar_view:
  path: '/MyModule/calendar/view/{id}'
  defaults:
    _controller: '\Drupal\ek_projects\Controller\CalendarController::view'
  requirements:
    _permission: 'calendar'

The id in MyModule_calendar_view route is a key used to indentify the type of data to be retrieved. In our case we display different events from dates of projects status and tasks thus we filter base on event type in our controller (submission, start, deadline, etc...). But this can be adapted to your own case.

3) The form to filter content display

This form is very simple and used to filter the events and trigger the calendar display.

filter

Here is the function buildForm into the SelectCalendar Class

public function buildForm(array $form, FormStateInterface $form_state) {

    $options = [ 0 => t('Calendar'), 1 => t('My tasks') , 2 => t('Projects submission'),    3 => t('Projects validation'), 4 => t('Projects start')],

    $form['select'] = array(

      '#type' => 'select',

      '#id' => 'filtercalendar',

      '#options' => $options,

      '#attributes' => array('title' => t('display options'), 'class' => array()),

      );

        return $form;  

  }

validateForm and submitForm are not used as the form is actually never submitted;

4) Create the controller

In our controller CalendarController.php we have 2 functions that match the above routes: calendar() and view() plus a third function to display the calendar in a dialog box: dialog();

calendar() function is very basic as it only call the dialog box.

  /**
   * AJAX callback handler for Ajax Calendar Dialog
   */
  public function calendar() {
    return $this->dialog(TRUE);
  }

So when using route /MyModule/calendar, the actual response happens in the dialog function:

/**
   * Render dialog in ajax callback.
   *
   * @param bool $is_modal
   *   (optional) TRUE if modal, FALSE if plain dialog. Defaults to FALSE.
   *
   * @return \Drupal\Core\Ajax\AjaxResponse
   *   An ajax response object.
   */
  protected function dialog($is_modal = FALSE) {
 
    $content = $this->formBuilder->getForm('Drupal\MyModule\Form\SelectCalendar');
    $content['content']['#markup'] = "<div id='calendar'></div>";
                        
    $response = new AjaxResponse();
    $title = t('Calendar');
    $l =  \Drupal::currentUser()->getPreferredLangcode();
    $content['#attached']['drupalSettings'] = array('calendarLang' => $l );
    $content['#attached']['library'] = array('core/drupal.dialog.ajax', 'ek_projects/ek_projects.calendar');
    $options = array('width' => '80%');
    
    if ($is_modal) {
      $dialog = new OpenModalDialogCommand($title, $content, $options);
      $response->addCommand($dialog);
    }
    else {
      $selector = '#ajax-text-dialog-wrapper-1';
      $response->addCommand(new OpenDialogCommand($selector, $title, $html));
    }
    return $response;
  }

Note: first the form to filter data is included with $this->formBuilder->getForm('Drupal\MyModule\Form\SelectCalendar');. Then we add a simple div markup in the content that will hold the calendar display generated by the plugin: $content['content']['#markup'] = "<div id='calendar'></div>"; Finally, the Ajax response is built with necessary parameters. The proper core ajax and dialog references in the controller are needed for the dialog to work as expected:

use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\OpenModalDialogCommand;
use Drupal\Core\Ajax\OpenDialogCommand;


The view() function is where the customisation part is the most relevant. This function collect data to display in a database and send them back in appropriate format. Thus we will only show the basic structure as it may apply to multiple data sources or formats.

  /**
   * AJAX callback handler for task and event display in calendar
   */
  public function view($id) {
      $color_array = array('#CEE3F6','#CEEFF6','#CEF6F0','#CEE3F6','#CEF6D4');

// this array will hold the data to be send back for dispay
      $events=array();
 
      switch($id) {
          
          case 1 :
     
          //1 My tasks
          // query data here and format it
          // sample event value format required by fullCalendar:
          //        
          //      $values = array(
          //          'id' => 'entry id',
          //          'title' => 'string',
          //          'description' => 'string',
          //          'start' => 'date',
          //          'end' => 'date',
          //          'url' => "MyModule/path",
          //          'allDay' => 'True/False',
          //          'className' => "",
          //          'color' => $color_array[$n],  
          //          'textColor' => 'black',
          //      );
          //      array_push($events, $values);
            
              break;     
            case 2 :
              // query data here
              break;
            case 3 :
              // query data here      
              break;
            
            //etc.
        
    }
      return new JsonResponse($events);        
   }

5) Javascript

Here is the code in js/cal/calendar_script.js:

(function ($, Drupal, drupalSettings) {

  Drupal.behaviors.MyModule_calendar = {
    attach: function (context, settings) {
      
        jQuery( "#filtercalendar" )
          .bind( "change", function( event ) {
              jQuery('#loading').show();
              var h = screen.height;
              var w = screen.width;
              var dh = h*0.8;
              var dw = dh;
              var top = (h-dh)/3;
              var left = (w-dw)/2;
              jQuery('.ui-dialog').css({top: top});
              var option = jQuery(this).val();
              display_calendar(option,settings.calendarLang);
          });

    }
  };
 
    
  function display_calendar(e,calendarLang) {
        jQuery('#calendar').fullCalendar( 'destroy' );
        jQuery('#calendar').fullCalendar({
            header: {
                left: 'prev,next today',
                center: 'title',
                right: 'month,agendaWeek,agendaDay'
            },
            eventMouseover:true,
            lang: calendarLang,
            events: {
                url: drupalSettings.path.baseUrl + "MyModule/calendar/view/" + e,
                error: function() {
                    jQuery('#calendar-warning').show();
                }
            },
                        aspectRatio:  1.8,
                        timeFormat: 'H(:mm)',
                        agenda: 'h:mm{ - h:mm}',
                        loading: function(bool) {
                            jQuery('#loading').toggle(bool);
                        },
                        eventRender: function(event, element) {
                                element.qtip({
                                    content: event.description,
                                    target: 'mouse',
                                    adjust: { x: 5, y: 5 }
                                });
                            }
            });
  }  

})(jQuery, Drupal, drupalSettings);

What happen here is that the function display_calendar() which actually trigger the fullCalendar plugin action is bind to the form that filters the data and identified by its id '#filtercalendar'. This function simply call  fullCalendar with necessary options (including the events that are pulled from view()) and display it into the html div markup identified by its id  '#calendar'.

Feel free to comment or suggest other ways of creating a calendar.

Jan 05 2016
Jan 05

Sometime in April 2014, we started an ambitious project to "translate" into Drupal 8 modules an in-house developed back-office management solution.

This back-office system, EK, was initiated in 2006 for internal needs. It was developed in PHP with mysql data storage. The initial idea was to have a tool that could help a new company to run its business as efficiently as possible with few constraints that are always critical in a good organization: central and unique data references (share unique information accross offices); simple to use with minimum training or learning curve (no resource for that!); flexible and cost effective; access control and security.

This system was used later in different business environments: trading, distribution and services. Along the way it was extended to new functions and capabilities based on the requirements of the users including multilingual needs as it was used in various countries with different working languages. Thus from simple invoicing and projects follow-up tool it soon covered various back-office and collaborative modules like documents management, accounting journal, cash management, internal claims and invoices, budget and reporting, duty roster to name only a few.

documents

The key point was that it always tried to be as user friendly as possible and as standardized as possible, always identifying best practices in back-office management that could benefit any organisation. In few words EK is based on hands-on experience in managing and optimising companies workflow.

But the initial framework developped for this in-house solution became more and more difficult to extend and maintain as it grew.

Therefore Drupal 8 became an obvious solution for a migration. The main reasons are:

  • The modular structure of Drupal fits perfectly with original back-office tools;
  • Drupal 8 offers a new and professional development framework;
  • There is a large community for support and further development ;
  • There is no distribution in Drupal (8) that provides a comprehensive back-office management.

The last point is also good answer to the common question that may arise about why proposing a new back-office management solution while there are so many players (from small to extra large) with open source or subscription scheme available? EK offers different alternatives to companies and vendors in a Drupal environment.

sales dashboard

EK as Drupal 8 modules as been used for almost 1 year to run real businesses with companies using it for their daily management tasks, cost racking and collaborative tools from sales, payroll, accounting to projects management. Thus we can say it is today a genuine solution that runs extremely well with Drupal 8, is easy to maintain and extend.

But there is still plenty of work and improvement as necessarily it is a young and highly perfectible project.

The modules are proposed as a sandbox project with the ambition to turn it into a real distribution that could apply to multiple back-office requirements for small and medium companies. It is also an opportunity to contribute and share experience with Drupal community.

Dec 23 2015
JK
Dec 23

In this article will will show a solution to add an ajax call to populate multiple information in a Drupal 8 form textarea element.

In this example, the script will autocomplete users list in the form for a custom module called MyModule. The user will enter first 2 letters of a name or email.

1) create a library

In MyModule.libraries.yml add the necessary javascript reference that will be used to populate the users in the form:


MyModule_lib:
  version: VERSION
  js:
    js/autocomplete.js: {}
  dependencies:
    - core/jquery
    - core/jquery.ui.autocomplete

The autocomplete function as dependencies which are based on jQuery library.

2) JS script

The jQuery autocomplete.js file that we use is copied below. It is implemented as Drupal behaviors You need to add this file in MyModule/js/ folder.
 

JS script:

(function ($, Drupal, drupalSettings) {

  Drupal.behaviors.MyModule_autocomplete = {
    attach: function (context, settings) {
         
    
      jQuery(function() {
        
        function split( val ) {
          return val.split( /,\s*/
          );
          }

        function extractLast( term ) {
          return split( term ).pop();
          }
          jQuery( "#edit-users" )
          .bind( "keydown", function( event ) {
            if ( event.keyCode === jQuery.ui.keyCode.TAB &&
            jQuery( this ).data( "ui-autocomplete" ).menu.active ) {
              event.preventDefault();
            }
          })
          .autocomplete({
            source: function( request, response ) {
              jQuery.getJSON("mypath/autocomplete", {
              term: extractLast( request.term )
              }, response );
            },
            search: function() {
              // custom minLength
              var term = extractLast( this.value );
                if ( term.length < 2 ) {
                  return false;
                }
            },
            focus: function() {
              // prevent value inserted on focus
              return false;
            },
            select: function( event, ui ) {
              var terms = split( this.value );
              // remove the current input
                terms.pop();
              // add the selected item
                terms.push( ui.item.value );
              // add placeholder to get the comma-and-space at the end - used for multi select
                terms.push( "" );
                this.value = terms.join( ", " );
              
              return false;
            }
        });
      });
    }
  };
})(jQuery, Drupal, drupalSettings);

3) Route

In the script above, the "path/autocomplete" is the path to the controller function that will handle and return the queries.

In your file "MyModule.routing.yml", you need to have a route to the function like:

form_autocomplete:
  path: '/mypath/autocomplete'
  defaults:
    _controller: '\Drupal\MyModule\Controller\MyController::autocomplete'

4) Controller

In MyController class, the autocomplete function will return the list of names that will be populated in the form textbox:

public function autocomplete(Request $request) {

        $text = $request->query->get('term');

        $query = "SELECT distinct name from {users_field_data} WHERE mail like :t1 or name like :t2 ";
        $a = array(':t1' => "$text%", ':t2' => "$text%");
        $name = db_query($query, $a)->fetchCol();

        return new JsonResponse($name);
    }

In the function above the 'term' is the user input. The script will query the database for users names or users emails that match the input and will return it as a Json array.

5) The form

In the custom module form, add the textarea element:

    $form['users'] = array(
      '#type' => 'textarea',
      '#rows' => 2,
      '#attributes' => array('placeholder' => t('enter recipients name separated by comma.')),
      '#required' => TRUE,
      '#default_value' => NULL,

6) the result

The result will show as below. When the user type first 2 letters of the name he is looking for, a list of matches will be displayed below the box and added to the list in the textarea when clicked. The values of the list are separated by a comma (see the terms.join( ", " ) function in autocomplete.js).

autocomplete textarea
Oct 24 2015
DA
Oct 24

Configuring Drupal with a remote database is possible and in some cases recommended.

Our EK management tools application is a particularly good case where this setup is very helpful. EK manages Drupal system configuration database and content database separately. In other words, the installed database from Drupal 8 and the database where all EK custom modules save their data are different.

This configuration brings few benefits:

  • security: is system data are compromised (i.e. user login information) the content data which is of high value for the organization have less chances to be affected. This also reinforced as the 2 databases are physically and geographically separated;
  • backup management; you can have a better backup management cycle with separated databases;
  • updates management; when updating Drupal, having a configuration database distinct from where the content is store will make it more efficient and faster;
  • as a vendor, you can keep control on the service by reserving control on the system configuration without having any interference on the customer content and information;

 The diagram below shows how we can implement an EK installation with remote database:

remote Drupal database setup

One important point is to keep the connection between the main server (this server can be the customer server for instance) and the configuration server secured with encrypted communication (ssl).

A good solution (we are experimenting now) comes with Amazon RDS service. The service can supply on demand access to mysql databases or any other database engine) and provide the necessary encryption keys.

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