Symfony 3 Ajax (EntityType) CollectionType

Objective: Create an efficient way to use a Symfony CollectionType of Entities. I wanted to be able to provide a Dijit FilteringSelect (autocomplete) for a collection of entities.

Issues: Using a CollectionType of EntityTypes loaded all the entities into a selection statement. This is fine if there are only a few entities, but not if there are hundreds. Also, model transformers cannot be used with CollectionTypes.

Solution: Create a custom FieldType and apply a model transformer.

The custom field type is based off a TextType, which allows the id of the entity to be passed as text, then transformed by the transformer. Transformers are described here.

The Dijit FilteringSelect only needs an id to serve as an anchor within the page layout. One may argue it could all be dynamic, but I like to use the HTML as a foundation. A text input is used as the base element which is replaced by the Dijit widget. The widget is created and populated with the data from a store.

PHP

<?php
 
namespace AppBundle\Form\Admin\Asset\Type;
 
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use AppBundle\Form\Admin\Asset\DataTransformer\ModelToIdTransformer;
 
class ModelRelationshipType extends AbstractType
{
 
    private $modelToIdTransformer;
 
    public function __constructModelToIdTransformer $modelToIdTransformer )
    {
        $this->modelToIdTransformer $modelToIdTransformer;
    }
 
    /**
     * @param FormBuilderInterface $builder
     * @param array $options
     */
    public function buildFormFormBuilderInterface $builder, array $options )
    {
 
        $builder
                ->addModelTransformer$this->modelToIdTransformer );
    }
 
    public function configureOptionsOptionsResolver $resolver )
    {
        // Stub
    }
 
    public function getParent()
    {
        return TextType::class;
    }
 
}

The new custom field type was then added as a service, with the transformer injected in:

Code

app.admin.field.type.model_relationship:
        class: AppBundle\Form\Admin\Asset\Type\ModelRelationshipType
        arguments: ['@app.form.data_transformer.model']
        tags:
            - { name: form.type, alias: 'app_model_relationship_type' }

The Twig template code to display each row of the collection uses a macro, because there are four of these fields.

{% block _model_extends_entry_row %}
{% import 'form/macros.html.twig' as form_macros %}
{{ form_macros.model_relationship_entry_row('requires',form) }}
{% endblock %}

{%macro relation(title,type,form) %}{%spaceless%}
        <div id="{{type}}" class="{{type}}">
            <h3>{{title}}</h3>
            {{form_row(form)}}
            {% if form.vars.allow_add %}
                <div data-type="{{type}}" class="add-one-more-row">{{ 'common.add_one_more'|trans}}
            {% endif %}
        </div>
{%endspaceless%}{%endmacro %}

To use the new field type in the form, you add the CollectionType, and the entry_type is ModelRelationshipType.

PHP

->add'requires'CollectionType::class, [
                    'entry_type' => ModelRelationshipType::class,
                    'required' => false,
                    'label' => false,
                    'empty_data' => null,
                    'allow_add' => true,
                    'allow_delete' => true,
                    'delete_empty' => true
                ] )

KnpMenu - JSON Renderer to support Dojo Dijit/Tree - Ajax

This is a modified version of the earlier post which created a static JSON representation of a menu.

What makes it different is that the earlier version rendered the items from the MenuBuilder into a static JSON object that was embedded in a page with a script tag. This approach removes the script tag (and the unnecessary twig template elements) and switches to a more graceful and efficient lazy loading menu.

The menu renderer is organizing the menu into the JSON format used by a Dijit Tree. This is set up for lazy loading, so it it only delivering the items in the assets tree. Items with URLs are pages, which you may click on to view the page, or expand to load more of the menu. These elements are defined in the menu builder (which isn't included in this post).

Dijit/Tree JSON

Code

{
  "id": "admin-assets",
  "name": "Assets",
  "uri": null,
  "has_children": true,
  "parent": "admin",
  "children": [
    {
      "id": "equipment",
      "name": "Equipment",
      "uri": "\/admin\/asset\/equipment",
      "has_children": false,
      "parent": "admin-assets"
    },
    {
      "id": "issues",
      "name": "Issues",
      "uri": "\/admin\/issue\/issue",
      "has_children": false,
      "parent": "admin-assets"
    },
    {
      "id": "manufacturers",
      "name": "Manufacturers",
      "uri": "\/admin\/asset\/manufacturer",
      "has_children": true,
      "parent": "admin-assets"
    },
    {
      "id": "trailers",
      "name": "Trailers",
      "uri": "\/admin\/asset\/trailer",
      "has_children": true,
      "parent": "admin-assets"
    },
    {
      "id": "vendors",
      "name": "Vendors",
      "uri": "\/admin\/asset\/vendor",
      "has_children": true,
      "parent": "admin-assets"
    },
    {
      "id": "configuration",
      "name": "Configuration",
      "uri": null,
      "has_children": true,
      "parent": "admin-assets"
    }
  ]
}

Content Menu Options

The content menu options allow the user to navigate directly to content they have created in the system. In this case, it is manufacturers. As above, if a URL is provided, the user may go to the page indicated by the uri attribute, or expand the menu item to view the children. The children under a manufacturer menu item are brands.

The URL is /api/menustore/adminmenus/manufacturers.

Code

{
  "children": [
    {
      "id": "manufacturer-14",
      "name": "Pelican",
      "parent": "manufacturer",
      "has_children": "1",
      "uri": "\/admin\/asset\/manufacturer?name=Pelican"
    },
    {
      "id": "manufacturer-2",
      "name": "Shed",
      "parent": "manufacturer",
      "has_children": "0",
      "uri": "\/admin\/asset\/manufacturer?name=Shed"
    },
    {
      "id": "manufacturer-1",
      "name": "Barn",
      "parent": "manufacturer",
      "has_children": "1",
      "uri": "\/admin\/asset\/manufacturer?name=Barn"
    }
  ]
}

Lazy loaded element

The content menu for the manufacturer named Barn follows. This leaf of the tree doesn't have any children. Clicking on it will take the user to the URL in the uri attribute.

The URL is /api/menustore/adminmenus/manufacturer-1

Code

{
  "children": [
    {
      "id": 12,
      "name": "Barn",
      "parent": "manufacturer-1",
      "uri": "\/admin\/asset\/manufacturer\/Barn\/brand\/Barn",
      "has_children": false,
      "children": null
    }
  ]
}

This can be read as the manufacturer's name is Barn and they have one brand, Barn. If you click on the brand Barn under the manufacturer Barn, you will go to a page to manage the brand Barn.

The render code which returns the menu rendered ready for a Dijit/Tree. Reference.

PHP

public function renderItemInterface $item, array $options array() )
    {
        $options array_merge$this->defaultOptions$options );
        
        $translator $options['translator'];
 
        $itemIterator = new \Knp\Menu\Iterator\RecursiveItemIterator$item );
 
        $iterator = new \RecursiveIteratorIterator$itemIterator, \RecursiveIteratorIterator::SELF_FIRST );
 
        $items = [];
        foreach( $iterator as $item )
        {
            $translatedLabel $translator->trans($item->getLabel());
            $id $item->getName();  
            $itemData = [ 'id' => strtolower$item->getName() ), 'name' => $translatedLabel'uri' => $item->getUri()];
            $itemData['has_children'] = $item->hasChildren();
            $parentId $item->getParent()->getName();
            if ($parentId !== $id) {
                $itemData['parent'] = strtolower($parentId);
                if (!isset($items[$parentId]['children'])) {
                    $items[$parentId]['children'] = [];
                }
                $items[$parentId]['children'][] = $itemData;
            }
            if (isset($items[$id])) {
                $items[$id] = array_merge($itemData$items[$id]);
            } else {
                $items[$id] = $itemData;
            }
        }
        return $items;
    }

The menu builder and renderer are defined as services.

Code

app.menu_builder:
        class: AppBundle\Menu\MenuBuilder
        arguments: ["@knp_menu.factory"]
        calls:
            - [setContainer, ["@service_container"]]
        tags:
            - { name: knp_menu.menu_builder, method: createAdminMenu, alias: admin }
            - { name: knp_menu.menu_builder, method: createCalendarMenu, alias: calendar }
            - { name: knp_menu.menu_builder, method: createMainMenu, alias: main }
            - { name: knp_menu.menu_builder, method: createUserMenu, alias: user }
            - { name: knp_menu.menu_builder, method: createVendorMenu, alias: vendor }
 
    app.menu_renderer:
        # The class implements Knp\Menu\Renderer\RendererInterface
        class: AppBundle\Menu\JsonRenderer
        arguments: [ "@twig",  "knp_menu.html.twig", "@knp_menu.matcher", {"translator": "@translator" }]
        tags:
            # The alias is what is used to retrieve the menu
            - { name: knp_menu.renderer, alias: json }

The static routing is as follows (this is output from

php bin/console debug:route

r) :


app_admin_api_menu_store_get_adminmenu GET ANY ANY /api/menustore/adminmenus
app_admin_api_menu_store_get_adminmenu_alt GET ANY ANY /api/menustore/adminmenus/
app_admin_api_menu_store_get_adminmenu_parent GET ANY ANY /api/menustore/adminmenus/?parent={parent}
app_admin_api_menu_store_get_adminmenu_id GET ANY ANY /api/menustore/adminmenus/{id}

The code which delivers the admin menu follows.

This code is delivering the static menu elements, those defined in the MenuBuilder, using the JsonRenderer listed above.

They provide references which allow the menu to lazy load additional content.

The JSON listed above is from, /api/menustore/adminmenus/admin-assets.

Menu elements for content, are listed first by the name of the content type (trailers) and allow loading of the contents of the

app_admin_api_store_get_trailer GET ANY ANY /api/store/trailer
app_admin_api_store_get_trailercontents GET ANY ANY /api/store/trailercontents/{id}

PHP

/**
     * @View()
     * @Get("/adminmenus")
     * @Get("/adminmenus/", name="_alt")
     * @Get("/adminmenus/?parent={parent}", name="_parent", defaults={"parent" = "admin"})
     * @Get("/adminmenus/{id}", name="_id", defaults={"id" = "admin"})
     *
     */
    public function getAdminmenuActionRequest $request )
    {
        $this->denyAccessUnlessGranted'ROLE_ADMIN'null'Unable to access this page!' );
        $adminMenu $this->get'app.menu_builder' )->createAdminMenu( [] );
        $renderer $this->get'app.menu_renderer' );
        $menu = [];
        $id $request->get'id' );
        if( $id !== null )
        {
            // This renders URLs without an id
            if( !in_array$id$this->entityMenus ) && preg_match'/\-\d+$/'$id ) === )
            {
                $menu $renderer->render$adminMenu, ['depth' => 1] );
                $menu $menu[$id];
            }
            else
            {
                // This renders dynamic menu elements - the dynamicId would be manufacturer for this blog post
                $dynamicId trimucfirstpreg_replace'/^([a-z]+).*$/''$1'$id ) ), 's' );
                $menuMethod 'get' $dynamicId 'Menu';
                if( method_exists$this$menuMethod ) )
                {
                    $menu $this->{$menuMethod}( $adminMenu$renderer$id );
                }
            }
        }
        $parent $request->get'parent' );
        if( $parent !== null )
        {
            foreach( $adminMenu as $name => $children )
            {
                if( $name === $parent )
                {
                    $menu['id'] = $parent;
                    $menu['name'] = $name;
                    $menu['children'] = $renderer->render$children, ['depth' => 1], 'json' );
 
                    break;
                }
            }
        }
        if( isset( $menu['children'] ) )
        {
            foreach( $menu['children'] as $c => $child )
            {
                if( in_array$child['id'], $this->entityMenus ) )
                {
                    $menu['children'][$c]['has_children'] = true;
                }
            }
        }
        return $menu;
    }

This code handles the lazy loading. The id may be manufacturers or manufacturer-id. If it is the former, a list of the manufacturers will be returned, the later will return all the brands for a manufacturer. If the system is likely to have a large number of manufacturers or sub items, you should add another layer to organize the elements by the first letter of their name - so you can click on A to see all the manufacturers whose name starts with A.

PHP

function getManufacturerMenu$adminMenu$renderer$id )
    {
        $limit 2500// TODO: Change to deliver first letters if there are too many manufacturers
        $em $this->getDoctrine()->getManager();
        $base explode'-'$id );
        switch( $base[0] )
        {
            case 'manufacturers':
                $queryBuilder $em->createQueryBuilder();
                $queryBuilder
                        ->select( ["CONCAT('manufacturer-',m.id) AS id"'m.name'"'manufacturer' AS parent"'COUNT(b.id) AS has_children'] )
                        ->from'AppBundle\Entity\Asset\Manufacturer''m' )
                        ->leftJoin'm.brands''b' )
                        ->orderBy'm.name' )
                        ->groupBy'm.id' )
                        ->setFirstResult)
                        ->setMaxResults$limit );
                $menu $renderer->render$adminMenu['admin']['admin-assets']['manufacturers'] );
                $children $queryBuilder->getQuery()->getResult();
                $l count$children );
                if( $l $limit )
                {
                    for( $i 0$i $l$i++ )
                    {
                        $children[$i]['uri'] = $this->generateUrl(
                                'app_admin_asset_manufacturer_index', ['name' => $children[$i]['name']], true ); // absolute
                    }
                }
                $menu['children'] = $children;
                break;
            case 'manufacturer':
                $manufacturer $em->getRepository'AppBundle\Entity\Asset\Manufacturer' )->find$base[1] );
                $brands $manufacturer->getBrands();
                $children = [];
                foreach( $brands as $b )
                {
                    $children[] = [
                        'id' => $b->getId(),
                        'name' => $b->getName(),
                        'parent' => $id,
                        'uri' => $this->generateUrl(
                                'app_admin_asset_manufacturer_getmanufacturerbrand', ['mname' => $manufacturer->getName(), 'bname' => $b->getName()], true ), // absolute
                        'has_children' => false,
                        'children' => null];
                }
                $menu['children'] = $children;
                break;
        }
        return $menu;
    }

The (Dojo) client side code:

Code

define([
    "dojo/dom",
    "dojo/store/Memory",
    "dijit/tree/ObjectStoreModel",
    "dojo/store/JsonRest",
    "dijit/Tree",
    "dojo/domReady!"
], function (dom,
        Memory, ObjectStoreModel, JsonRest, Tree) {
//"use strict";
    function run() {
 
        var store = new JsonRest({
            target: "/api/menustore/adminmenus/",
            getChildren: function (object) {
                return this.get(object.id).then(function (fullObject) {
                    return fullObject.children;
                });
            }
        });
 
        var model = new ObjectStoreModel({
            store: store,
            mayHaveChildren: function (object) {
                return object.has_children;
            }, getRoot: function (onItem) {
                this.store.get("admin").then(onItem);
            },
        });
        // Create the Tree.
        var tree = new Tree({
            id: "admin-menu",
            model: model,
            persist: true,
            onClick: function (item) {
                if( typeof item.uri !== "undefined" && item.uri !== null ) {
                    location.href = item.uri;
                }
            },
            getIconClass: function (item, opened) {
                return (item && item.has_children) ? (opened ? "dijitFolderOpened" : "dijitFolderClosed") : "dijitLeaf"
            }
        });
        tree.placeAt(dom.byId("admin-left-menu"));
        tree.startup();
    }
    return {
        run: run
    };
});
//# sourceURL=menu.js

KnpMenu - JSON Renderer to support Dojo Dijit/Tree

I wrote some code that parsed through the default KNP menu HTML and created a Dijit menu. It worked, but it was slow and awkward.

I couldn't find an easy way to get the KNP menu in the JSON form that would work well with Dijit/Tree, so I wrote my own.

JsonRenderer.php

Code

<?php
 
namespace AppBundle\Menu;
 
use Knp\Menu\ItemInterface;
use Knp\Menu\Matcher\MatcherInterface;
use Knp\Menu\Renderer\RendererInterface;
use Translator;
 
class JsonRenderer implements RendererInterface
{
 
    /**
     * @var \Twig_Environment
     */
    private $environment;
    private $matcher;
    private $defaultOptions;
 
    /**
     * @param \Twig_Environment $environment
     * @param string            $template
     * @param MatcherInterface  $matcher
     * @param array             $defaultOptions
     */
    public function __construct( \Twig_Environment $environment, $template, MatcherInterface $matcher, array $defaultOptions = array() )
    {
        $this->environment = $environment;
        $this->matcher = $matcher;
        $this->defaultOptions = array_merge( array(
            'depth' => null,
            'matchingDepth' => null,
            'currentAsLink' => true,
            'currentClass' => 'current',
            'ancestorClass' => 'current_ancestor',
            'firstClass' => 'first',
            'lastClass' => 'last',
            'template' => $template,
            'compressed' => false,
            'allow_safe_labels' => false,
            'clear_matcher' => true,
            'leaf_class' => null,
            'branch_class' => null
                ), $defaultOptions );
  
    }
 
    public function render( ItemInterface $item, array $options = array() )
    {
        $options = array_merge( $this->defaultOptions, $options );
        
        $translator = $options['translator'];
 
        $itemIterator = new \Knp\Menu\Iterator\RecursiveItemIterator( $item );
 
        $iterator = new \RecursiveIteratorIterator( $itemIterator, \RecursiveIteratorIterator::SELF_FIRST );
        
        $items = [];
        foreach( $iterator as $item )
        {
            $translatedLabel = $translator->trans($item->getLabel());
            $id = $item->getName();
            $parentId = $item->getParent()->getName();
            $itemData = [ 'id' => strtolower( $item->getName() ), 'name' => $translatedLabel, 'uri' => $item->getUri()];
            if ($parentId !== $id) {
                $itemData['parent'] =strtolower($parentId);
            }
            $itemData['has_children'] = $item->hasChildren();
            $items[] = $itemData;
        }
        $lastItem = count( $items ) - 1;
        $items[$lastItem]['lastItem'] = true;
 
        $html = $this->environment->render( $options['template'], array('items' => $items, 'options' => $options, 'matcher' => $this->matcher) );
 
        if( $options['clear_matcher'] )
        {
            $this->matcher->clear();
        }
        return $html;
    }
 
}

Added JsonRenderer as a service in services.yml

Code

app.menu_renderer:
        # The class implements Knp\Menu\Renderer\RendererInterface
        class: AppBundle\Menu\JsonRenderer
        arguments: [ "@twig",  "knp_menu.html.twig", "@knp_menu.matcher", {"translator": "@translator" }]
        tags:
            # The alias is what is used to retrieve the menu
            - { name: knp_menu.renderer, alias: json }

I used a twig template (knp_menu.html.twig) to output the JSON, perhaps in the future I will update it to deliver it as a .js file.

Code

var menuTreeStoreData = [{% for item in items%}{{item|json_encode()|raw}}{% if item.lastItem is not defined %},{%endif%}{% endfor %}];

The layout template calls the renderer with this line:

Code

{{ knp_menu_render('admin',{'template': 'admin/parts/knp_menu.html.twig'}, 'json') }}

Finally, this is menu.js which uses the menu data produced by JsonRenderer to create a nice tree for a menu. You could use a different menu approach. I kind of like the tree for now.

Code

define([
    "dojo/dom",
    "dojo/store/Memory",
    "dijit/tree/ObjectStoreModel",
    "dijit/Tree",
    "dojo/domReady!"
], function (dom,
        Memory, ObjectStoreModel, Tree) {
//"use strict";
    function run() {
        var store = new Memory({data: menuTreeStoreData
            ,
            getChildren: function (object) {
                return this.query({parent: object.id});
            }});
        var model = new ObjectStoreModel({
            store: store,
            query: {id: 'admin'}
        });
        // Create the Tree.
        var tree = new Tree({
            id: "admin-menu",
            model: model,
            persist: true,
            onClick: function (item) {
                if( typeof item.uri !== "undefined" && item.uri !== null ) {
                    location.href = item.uri;
                }
            },
            getIconClass: function (item, opened) {
                return (item && item.has_children) ? (opened ? "dijitFolderOpened" : "dijitFolderClosed") : "dijitLeaf"
            }
        });
        tree.placeAt(dom.byId("admin-left-menu"));
        tree.startup();
    }
    return {
        run: run
    };
});

Squirrel Mail - Change Password Plugin - Wicked Simple Version

It's a long sad story as to why I set up an email server. Mostly long. Arguably not sad, since it is collecting email.

However, lacking the usual accessories provided by a hosting company and having issued a collection of awful (secure) passwords, it was abundantly clear a password reset mechanism would be required.

This isn't really suitable for password recovery, because it relies on the person being able to log into Squirrel Mail to change their password. If they have already forgotten it - they're toast.

Thus - one must encourage the users to set the password to something they have a chance at typing correctly and maybe remembering.

Please note I'm not implying the users are incapable of handling a password. I issued passwords which would be difficult to type and virtually impossible to remember.

Here's the code ...

PHP

define('SM_PATH''../../');
include_once(SM_PATH 'include/validate.php');
 
// Make sure plugin is activated!
//
global $plugins;
if (!in_array('change_password'$plugins))
   exit;
 
 
global $color;
displayPageHeader($color'None');
 
sqgetGlobalVar('username',   $username,     SQ_SESSION);
 
define('MIN_PASSWORD',10);
 
$error $message null;
$dbConn pg_connect('dbname=mail user=somebody password=supersecret');
if ($dbConn !== false) {
    if (!empty($_POST['current-password'])) {
        $currentPassword preg_replace('/[^a-z0-9\_\@\#\$\%\^\&\*\(\)\+\?\>\<\;\:]/i','',$_POST['current-password']);
        $query 'SELECT password FROM users WHERE userid = $1';
        $result pg_query_params($query,[$username]);
        if (pg_affected_rows($result) === 1) {
            $row pg_fetch_assoc($result);
            $passwordPieces explode('$',$row['password']);
            $encryptedCheck '{SHA512-CRYPT}' crypt($currentPassword'$6$'.$passwordPieces[2].'$');
            if ($encryptedCheck !== $row['password']) {
                $error 'Current password mismatch';
            }
        } else {
            $error 'Current user mismatch';
        }
    }
    
    if ($error === null && isset($_POST['new-password'],$_POST['confirm-password'])) {
        $newPassword trim($_POST['new-password']);
        $confirmPassword trim($_POST['confirm-password']);
        if (strlen($newPassword) < MIN_PASSWORD) {
            $error 'Password must be at least '.MIN_PASSWORD.' characters';
        } else {
            if ($newPassword !== $confirmPassword) {
                $error 'Passwords must match';
            } else {
                $checkForUppercase preg_match('/[A-Z]/',$newPassword);
                $checkForLowercase preg_match('/[a-z]/',$newPassword);
                $checkForDigit preg_match('/\d/',$newPassword);
                $checkForSymbol preg_match('/[\_\!\@\#\$\%\^\&\*\(\)\+\?\>\<\;\:]/',$newPassword);
                if ($checkForUppercase && $checkForLowercase && $checkForDigit && $checkForSymbol) {
                    $random str_shuffle(md5(rand(-1000,1000)));
                                        $encrypted '{SHA512-CRYPT}' crypt($newPassword'$6$'.$random.'$');
                    $query 'UPDATE users SET password = $1 WHERE userid = $2';
                    $result pg_query_params($query,[$encrypted,$username]);
                    if (pg_affected_rows($result) === 1) {
                        $message 'Success';
                    } else {
                        $error 'Unable to update the password';
                    }
                } else {
                    $error 'Password must contain at least one uppercase letter, one lowercase letter, one digit, and one symbol';
                }
            }
        }
    }
    pg_close();
} else {
    $error 'Unable to update the password';
}
 
?>
<?php if ($error !== null) : ?>
<div id="error" style="background-color:#fff;color:#000;padding:5px;margin:5px;">
<?= $error ?>
</div>
<?php endif ?>
<?php if ($message !== null) : ?>
<div id="message" style="background-color:#fff;color:#000;padding:5px;margin:5px;">
<?= $message ?>
</div>
<?php endif ?>
<form action="change_password.php" method="post">
<div class="block">
<label for="current-password">Current Password</label><br /><input type="password" name="current-password" id="current-password" />
</div>
<div class="block">
<label for="new-password">New Password</label><br /><input type="password" name="new-password" id="new-password" />
</div>
<div class="block">
<label for="confirm-password">Confirm Password</label><br /><input type="password" name="confirm-password" id="confirm-password" />
</div>
<p><br /><br />
<button type="submit">Submit</button><br />
<button type="reset">Reset</button></p>
</form>

The rest of the files to make the plugin work and the internationalization are left to you dear reader. This is just the stuff you need to make it work.

Enjoy.

The blog code formatter modified my HTML. That's okay - what I'm really sharing is the PHP.

Session Timeout Recovery

It can be difficult for the client to know if the session has timed out on the server. This post describes the use of session storage to save and restore the data on a page input when a session times out.

Let's start with the timeout detection.

On the server side, this tiny piece of code checks if the user is logged in.

PHP

<?php
session_start();
header('HTTP/1.1 '.(!empty($_SESSION['logged_in']) ? '204 No content' '403 Forbidden'));
exit;

The only response is either 204 No content which means the user is logged in, or 403 Forbidden, meaning the user isn't logged in or the session timed out.

On the client side, we'll start with the code that is "pinging" the server and saving the data:

Code

"use strict";
 
// pingStatus is the session storage key which will contain either SAVED or NOT_SAVED to indicate whether there is session data saved.
var pingStatus = '_$ping_status$_';
var SAVED = 'saved';
var NOT_SAVED = 'not_saved';
 
// PREFIX is the session prefix key, intended to avoid namespace conflicts between other sessionStorage code
var PREFIX = '__$';
 
// The values of these types of inputs are not saved - you may want to save hidden inputs depending on how your page is coded
var DONT_SAVE = ["file", "button", "submit", "reset", "hidden"];
 
// The ping function
function ping(key) {
    // key is used to identify the data - it can be any value you would like
    // A page name and id work well, that way, if there are multiple instances of the same page in the browser, conflicts should be avoided
    PREFIX = key;
 
    // pinger is the variable used to store the interval timer
    var pinger;
 
    // doPing pings the server
    function doPing() {
        var pReq = new XMLHttpRequest();
        pReq.addEventListener("load", function () {
            var i, items;
 
            // If the server doesn't respond with a 204, consider it a timeout
            if (this.status !== 204) {
 
                // Stop pinging
                clearInterval(pinger);
 
                // Allow the user to decide whether to save the data or abandon it
                if (confirm('Your session timed out.  Click OK save the page data or Cancel to discard any data that has not been sent to the server') === true) {
                    // Loop through all the inputs
                    items = document.querySelectorAll('input, select, textarea');
                    for (i = 0; i < items.length; i++) {
                        // Only save the items that aren't in the DONT_SAVE array
                        if (DONT_SAVE.indexOf(items[i].type) === -1 && items[i].id !== "") {
 
                            // If it isn't a checkbox or radio button - save the value
                            if (items[i].type !== "checkbox" && items[i].type !== "radio") {
                                sessionStorage.setItem(PREFIX + items[i].id, items[i].value);
                            } else {
                                // If it is a checkbox or radio button - save whether it was checked or not
                                sessionStorage.setItem(PREFIX + items[i].id, items[i].checked);
                            }
                        }
                    }
                    // Set the flag to indicate the data was saved
                    sessionStorage.setItem(pingStatus + key, SAVED);
                } else {
                    // Set the flag to indicate the data has not been saved
                    sessionStorage.setItem(pingStatus + key, NOT_SAVED);
                }
 
                // Location reload is assuming that when the page reloads, the server will redirect to the login page, then to the requested page - which is the current page
                location.reload();
            }
        });
 
        // Issue the ping request
        pReq.open("GET", "ping.php");
        pReq.setRequestHeader("Accept", "application/json");
        pReq.send();
    }
  
    // Set the flag to indicate the data has not been saved
    sessionStorage.setItem(pingStatus + key, NOT_SAVED);
 
    // Set up to ping every 10 seconds
    pinger = setInterval(doPing, 10000);
 
    // Do a first ping, which is effectively on page load.  This catches timeouts when the computer had gone to sleep
    doPing();
}

The restore code checks to see if the data has been saved and if there is data, allows the user to use it or discard it.

Code

function restore(key) {
    var v, i, items;
 
    PREFIX = key;
 
    // If there is data saved for this session
    if (sessionStorage.getItem(pingStatus + key) === SAVED) {
 
        // Give the user the opportunity to use it or ignore it
        if (confirm('There is data saved for this page, click OK to load it or Cancel to discard it') === true) {
 
            // Get all the inputs
            items = document.querySelectorAll('input, select, textarea');
            for (i = 0; i < items.length; i++) {
 
                // Ignore the 'DONT_SAVE's and anything that doesn't have an id
                if (DONT_SAVE.indexOf(items[i].type) === -1 && items[i].id !== "") {
                    v = sessionStorage.getItem(PREFIX + items[i].id);
 
                    // If there is no data saved for the input, it can't be restored
                    if (v !== null) {
 
                        // If it's not a checkbox or radio button, restore the value
                        if (items[i].type !== "checkbox" && items[i].type !== "radio") {
 
                            // If the value is different than what loaded, restore the value
                            if (items[i].value !== v) {
                                items[i].value = v;
                            }
                        } else {
                            // If it is a checkbox or radio button, set the checked state
                            items[i].checked = v;
                        }
                    }
                }
            }
        }
        // Indicate the data has not been saved
        sessionStorage.setItem(pingStatus + key, NOT_SAVED);
    }
}

Finally, to integrate it into the page, include the ping.js file in a script tag, create and save a key, then call restore(key) followed by ping(key).

Code

var key = "page"+someId;restore(key);ping(key);

Post courtesy of Game Creek Video

:: Next >>