Multiple forms, datatables, sqlite, and map


#1

Two Grav users have asked me on the sqlite repo about using multiple forms and tables to select/edit items. (Quick suggestion - use the Grav discuss forum for questions like this, because I have set up email digests from here, but not from the github repos).

To answer these questions, here is a short description of a page on a website I set up using Grav. You can see the page on mgc.1ptd.com Below is the page. It is located at user/page/mgc/default.md This is important because the forms generate responses that need to be processed by server side code that are associated with the route mgc.

In addition to the mgc plugin, which is private (but the php for the plugin is given below), I have used the following plugins, which I have published: sqlite, map-marker-leaflet, datatables, persistent-data. They are all independently documented, and a lot of the documented examples were simplified from the code I developed for this page.

Some notes:

  • sqlite can be used to output in json form as well as html table form. This was developed and used for the map-marker-leaflet input.
  • datatables is a fantastic jQuery plugin. I have only used a very small proportion of its functionality. The inclusion of the scripting shortcode in the plugin I published was intended to expose as much of its functionality as possible. You can see from the code below how I have used the row selection functions to populate elements of a Grav form.
  • I will admit that the population of the Grav form is somewhat of a hack. It took quite a bit of time looking at the form Grav generated in order to identify the variables that needed to be returned.
  • The persistent data plugin is used to separate two identities, one is the user (also by definition a hiker in the sqlite db) who has logged in, and a hiker chosen from this list of hikers. This allows
    1. any one to use this page of the site,
    2. when the selected hiker and the logged in user are the same, another form is made visible, (this is done with on-page twig that conditionally includes the other form), which can then be used to provide data that is added to the sqlite database,
    3. in another page - only visible to users who have logged in - it is possible to compare two hikers to see which mountain tops they have both done, which neither have done, etc. The idea is to help hikers plan hikes with others.
  • The options in the form change_map_style related to a map provider called thunderforest. In order to replicate the image on the url above, you would need to obtain a (free for hobbyists) account with thundercloud, and include it in the passkey in the map-leaflet-marker plugin customisation.
  • The mgc challenge is ending in March 2019, and only a few hikers have used the page. So you may not get the variety of colours to lable the different trek types. But the principle should be clear: use the sql select statement to create different categories and name them appropriately, use the marker lable colors to assign different colours to each category.
  • A tip: when data provided by the user (eg. the name of an image file) can be arbitrary, use the php function escapeString() as seen in the mgc.php code escapeString($img['name']). It took me a lonnnnnnng time to find this function as several variations exist.
  • The action field of a Grav form should point back to the page from which the form was generated. This allows Grav to associate the form response with the form processing code. The documentation is not crystal clear about this.
  • This page also shows how to allow a user to upload an image. Given the short-term nature of this page, I have not included any code to allow a user to delete an image.

Hope this example and the notes help. I have found the combination of the Datatables and Sqlite plugins to be very productive in generating different interfaces.

Here is the code.

---
title: Challenge
body_classes: title-center header-transparent sticky-footer
cache_enable: false
process:
    twig: true
forms:
    select-hiker:
        action: /mgc
        fields:
            - name: hiker
              type: hidden
            - name: hiked
              type: hidden
            - name: hiker_id
              type: hidden
            - name: hidden
              type: hidden
              default: "no"
        buttons:
            - type: submit
              value: Select Hiker
            - type: reset
              value: Deselect Hiker
            - type: submit
              value: Hide Table
              task: show
        process:
            - mgc: true
    show-hikers:
        action: /mgc
        fields:
            - name: hidden
              type: hidden
              default:  "no"
        buttons:
            - type: submit
              task: show
              value: Show Hiker Table
        process:
            - mgc: true
    change-map-style:
        action: /mgc
        fields:
            - name: style
              type: select
              label: 'Map Style'
              options:
                    outdoors: Outdoors
                    transport: Show Transport
                    transport-dark: Transport Dark
                    landscape: Landscape
                    mobile-atlas: Mobile Optimised
              default: outdoors
        process:
            - mgc: true
    update-hikes-form:
        action: /mgc
        fields:
            - name: peak
              type: hidden
            - name: peak_name
              type: display
              label: Peak reached
            - name: hiker
              type: hidden
            - name: hiked
              type: hidden
            - name: hiker_name
              type: hidden
            - name: done
              type: hidden
            - name: group
              type: select
              label: "Which type of hike was this?"
              help: "If not a meetup group, then self-guided. Please provide selfie at top."
              options:
                    'Meetup': 'Meetup Group'
                    'Self-guided': 'Self Guided'
            - name: meetuphike
              type: text
              validation:
                    required: true
            - name: images
              type: file
              label: 'Images of hike (jpeg/jpg/png)'
              multiple: true
              destination: user/images
              help: "Image(s) of your hike. Obligatory for self-guided. Only jpeg & png formats accepted"
              accept: [ 'image/jpeg', 'image/jpg', 'image/png' ]
        buttons:
            - type: submit
              value: Update peak
            - type: reset
              value: Reset
        process:
            - mgc-cleanup: true
            - userinfo:
                update: true
                include:
                    - hiked
            - reset: true
---
{% if userinfo or (mgc and mgc.hiker) %}
## Hiker: {{ (mgc and mgc.hiker)?mgc.hiker:userinfo.hiker }}, Peaks climbed: {{ (mgc and mgc.hiked) ? mgc.hiked : userinfo.hiked }}
{% else %}
## Please login or select hiker
{% endif %}

## Peak Map

[map-leaflet lat=22.387015  lng=114.160555 zoom=11 mapname=mgcpeaks height="600px" style="{{ ( mgc and mgc.style )? mgc.style : '' }}" scale]
[a-markers icon='' iconColor=black ]
[sql-table json]
SELECT latitude as lat, longitude as lng,
printf("%s | %s | %dm",eng_name,cn_name,altitude) as title,
peak_id as text,
CASE
    WHEN t1.hiked > 0 AND t1.inf=1 THEN 'salmon'
    WHEN t1.hiked > 0 AND t1.inf=2 THEN 'pink'
    WHEN t1.hiked > 0 AND t1.inf=3 THEN 'lightblue'
    ELSE 'lightgreen'
    END as markerColor
FROM peaks
LEFT JOIN (
    SELECT trek_id as hiked, peak,
        CASE
            WHEN meetuphike="No info" THEN 1
            WHEN meetuphike="Self-guided" THEN 2
            ELSE 3
        END AS inf
    FROM treks WHERE hiker="{{ mgc.hiker_id?mgc.hiker_id:(userinfo.hiker_id?userinfo.hiker_id:'') }}" ) as t1
ON t1.peak=peaks.peak_id
ORDER BY peak_id ASC
[/sql-table]
[/a-markers]
[/map-leaflet]

<div class="mgc-mk">
    <div style="background: #eb7d7f;">No information</div>
    <div style="background: #ff91ea;">Self-guided</div>
    <div style="background: #88daff;">Registered meetup hike</div>
    <div style="background: #bbf970;">Not hiked yet</div>
</div>
{% set options = {"outdoors": "Outdoors",
"transport": "Show Transport",
"transport-dark": "Transport Dark",
"landscape": "Landscape",
"mobile-atlas": "Mobile Optimised" }
%}

{% include "forms/form.html.twig" with { form: forms('change-map-style') } %}
<script>
$('select[name="data[style]"]').on('change', function() {
    $('#change-map-style').submit();
});
$(document).ready(function() {
    $('option[value="{{ mgc.style }}"]').prop('selected', true);
});
</script>
## Hiker Selection
{% if (mgc and mgc.hidden=="yes") %}
{% include "forms/form.html.twig" with { form: forms('show-hikers') } %}
{% else %}
[datatables]
[sql-table hidden=hiker_id]
SELECT fullname as Hiker, count(t2.peak) as "Peaks Completed", hiker_id  FROM hikers as t1
LEFT JOIN treks  as t2 ON t1.hiker_id=t2.hiker
GROUP BY t1.hiker_id
[/sql-table]
[dt-script]
var table = $(selector).DataTable();
$(selector + ' tbody').on( 'click', 'tr', function () {
    if ( $(this).hasClass('selected') ) {
        $(this).removeClass('selected');
        $('#select-hiker input[name="data[hiker_id]"]').val('');
        $('#select-hiker input[name="data[hiker]"]').val('');
        $('#select-hiker input[name="data[hiked]"]').val('');
    }
    else {
        table.$('tr.selected').removeClass('selected');
        $(this).addClass('selected');
        var rd = table.row('.selected').data();
        $('#select-hiker input[name="data[hiker_id]"]').val(rd[2]);
        $('#select-hiker input[name="data[hiker]"]').val(rd[0]);
        $('#select-hiker input[name="data[hiked]"]').val(rd[1]);
    }
} );
$("#select-hiker button").click(function(ev){
    ev.preventDefault();
    if($(this).attr("name")=="task") {
        $('#select-hiker input[name="data[hidden]"]').val("yes");
    } else if ($(this).attr('type') == 'reset') {
        table.$('tr.selected').removeClass('selected');
        $('#select-hiker input[name="data[hiker_id]"]').val(' ');
        $('#select-hiker input[name="data[hiker]"]').val(' ');
        $('#select-hiker input[name="data[hiked]"]').val(' ');
    } else {
        $('#select-hiker input[name="data[hidden]"]').val("no");
    }
    $("#select-hiker").submit();
});
[/dt-script]
[/datatables]

{% include "forms/form.html.twig" with { form: forms('select-hiker') } %}
{% endif %}

## Hike Information

[datatables]
[sql-table hidden="done peaks"]
SELECT peak_id as "Order", eng_name as "English Name", cn_name as "Chinese Name", altitude as "Altitude",
case when t2.hiked > 0 then 1 else 0 end as done,
t3.peaks as peaks,
CASE t2.meetuphike
WHEN 'No info' THEN t2.meetuphike
WHEN 'Self-guided' THEN t2.meetuphike
ELSE '<a href="' || t2.meetuphike || '"  target="_blank">_Meetup Group_</a>'
END as "Hiked with",
t2.images as "Images"
FROM peaks as t1
LEFT JOIN (SELECT trek_id as hiked, peak, meetuphike, images FROM treks
WHERE hiker="{{mgc.hiker_id?:(userinfo.hiker_id?:'')}}") as t2
on t1.peak_id = t2.peak,
(SELECT count(trek_id) as peaks FROM treks
WHERE hiker="{{mgc.hiker_id?:(userinfo.hiker_id?:'')}}") as t3
[/sql-table]
[dt-script]
    var table = $(selector).DataTable();
    table.rows().every( function () {
        var peak = this.data();
        if ( peak[4] == 1 ) {
            $(this.node()).addClass('mgc-hiked');
        }
    });
    $(selector + ' tbody').on( 'click', 'tr', function () {
        if ( $(this).hasClass('selected') ) {
            $(this).removeClass('selected');
            $('#update-hikes-form input[name="data[peak]"]').val('');
            $('#update-hikes-form input[name="data[hiker]"]').val('');
            $('#update-hikes-form input[name="data[done]"]').val('');
            $('#update-hikes-form input[name="data[hiker_name]"]').val('');
            $('#update-hikes-form input[name="data[hiked]"]').val('');
            $('#update-hikes-form div:first-of-type div:nth-of-type(2) div').html('undefined');
        }
        else {
            table.$('tr.selected').removeClass('selected');
            $(this).addClass('selected');
            var rd = table.row('.selected').data();
            $('#update-hikes-form input[name="data[peak]"]').val(rd[0]);
            $('#update-hikes-form input[name="data[hiker]"]').val('{{userinfo.hiker_id}}');
            $('#update-hikes-form input[name="data[done]"]').val(rd[4]);
            $('#update-hikes-form input[name="data[hiker_name]"]').val('{{userinfo.hiker}}');
            var pkdone=rd[5];
            if(rd[4] == 0) { pkdone = +pkdone +1; }
            $('#update-hikes-form input[name="data[hiked]"]').val( pkdone ); // incremented by operation if new
            $('#update-hikes-form div:first-of-type div:nth-of-type(2) div').html(rd[0] + ' - ' +rd[1]+' '+rd[2]+' (' + rd[3] + ')');
        }
    } );
    $('#update-hikes-form').on('reset', function(e) {
        setTimeout( function() {
            table.$('tr.selected').removeClass('selected');
            $('#update-hikes-form input[name="data[peak]"]').val('');
            $('#update-hikes-form input[name="data[done]"]').val('');
            $('#update-hikes-form input[name="data[hiker]"]').val('');
            $('#update-hikes-form input[name="data[hiker_name]"]').val('');
            $('#update-hikes-form input[name="data[hiked]"]').val('');
            $('#update-hikes-form div:first-of-type div:nth-of-type(2) div').html('undefined');
        });
    });
    $('#update-hikes-form select').on('change', function() {
        if ( this.value == "Self-guided") {
            $('#update-hikes-form div.form-group:has(input[name="data[meetuphike]"])').css('display','none');
            $('#update-hikes-form input[name="data[meetuphike]"]').val("Self-guided");
        } else {
            $('#update-hikes-form div.form-group:has(input[name="data[meetuphike]"])').css('display','');
            $('#update-hikes-form input[name="data[meetuphike]"]').val('');
        }
    });
[/dt-script]
[/datatables]
{% if userinfo and (userinfo.hiker == grav.user.fullname)
    and ((mgc and mgc.hiker == userinfo.hiker) or not mgc) %}
{% include "forms/form.html.twig" with { form: forms('update-hikes-form') } %}
{% endif %}

<img id="photo-area" class="mgc-photo"></img>
<script>
$('.mgc-th img').click(function(){
    $('#photo-area').attr('src',$(this).attr('src'));
    });
$('.mgc-th span').click(function() {
    $('#photo-area').attr('src',$(this).attr('data-src'));
    });
$('#photo-area').click(function() { $(this).attr('src',''); });
</script>

If you inspect the code, you will see that I have referenced a form processing action mgc. These are contained in a custom plugin because the code is specific to this particular page. I will not describe them, but for completeness and to help someone who might like to duplicate the effects, here is the code of the mgc.php for the plugin.

<?php
namespace Grav\Plugin;

use Grav\Common\Plugin;
use RocketTheme\Toolbox\Event\Event;
use RocketTheme\Toolbox\File\File;
use Symfony\Component\Yaml\Yaml;

class MgcPlugin extends Plugin
{
    public static function getSubscribedEvents()
    {
        return [
            'onPluginsInitialized' => ['onPluginsInitialized', 0]
        ];
    }

    public function onPluginsInitialized()
    {
        // Don't proceed if we are in the admin plugin
        if ($this->isAdmin()) {
            return;
        }

        // Enable the main event we are interested in
        $this->enable([
            'onUserLoginRegisterData' => ['onUserLoginRegisterData',0],
            'onFormProcessed' => ['onFormProcessed',0],
            'onTwigVariables' => ['onTwigVariables', 0]
        ]);
    }

    public function onTwigVariables() {
        $this->grav['twig']->twig_vars['mgc'] =
            $this->grav['session']->getFlashObject('mgc' );
    }

    public function onUserLoginRegisterData(Event $event) {
        $data = $event['data'];
        $un = $data['username'];
        $id  = $data['hiker_id'];
        // adjust the database
        $sql =<<<SQL
            UPDATE hikers SET username="$un"
            WHERE hiker_id="$id"
SQL;
        $db = $this->grav['sqlite']['db'];
        try {
          $db->exec($sql) ;
        } catch ( \Exception $e ) {
        }
        // adjust persistent data
        $path = DATA_DIR . 'persistent' . DS . $un . '.yaml';
        $datafh = File::instance($path);
        $userinfo['hiker'] = $data['fullname'];
        $userinfo['hiked'] = $data['hiked'];
        $userinfo['hiker_id'] = $id;
        $datafh->save(Yaml::dump($userinfo));
        chmod($path, 0664);
    }

    public function onFormProcessed(Event $event) {
        $action = $event['action'];
        $form = $event['form'];
        $data = $form->getData()->toArray();
        switch ($action) {
            case 'mgc':
                $stored = $this->grav['session']->getFlashObject('mgc');
                foreach( $data as $key => $value) {
                    $stored[$key] = ($value == ' ')? null : $value;
                }
                $this->grav['session']->setFlashObject('mgc', $stored );
                $this->grav['twig']->twig_vars['mgc'] = $stored;
                break;
            case 'mgc-cleanup':
                $hkname=str_replace(' ','_', $data['hiker_name'] );
                $images = '';
                if (isset($data['images']) && is_array($data['images'])) {
                    foreach( $data['images'] as $img ) {
                        if ( file_exists($img['path']) ) {
                            $im = 'user/images/' . "$hkname-" . $this->grav['sqlite']['db']->escapeString($img['name']);
                            $images .=  "<div class=\"mgc-th\"><img src=\"$im\"><span data-src=\"$im\">Image</span></div>";
                            rename($img['path'], $im );
                        }
                    }
                }
                // for UPDATE if necessary
                $set = isset($data['meetuphike']) ? "meetuphike=\"{$data['meetuphike']}\"":'';
                if  (isset($data['images']) ) {
                    $set .= ( $set?',':'' ) . "images='$images' ";
                }
                // check on meetuphike
                if (! isset($data['meetuphike'])
                    || ! preg_match('/Self\-guided|No info|https\:\/\/www\.meetup.com\/hongkonghikingmeetup\/events\/\d+/',$data['meetuphike']))
                    {
                        $data['meetuphike'] = 'invalid';
                }
                if ( isset($data['done']) && $data['done'] ) {
                    $sql = <<<SQL
                        UPDATE treks SET $set WHERE peak="{$data['peak']}" and hiker="{$data['hiker']}"
SQL;
                } else {
                    $sql = <<<SQL
                        INSERT INTO treks (hiker, peak, meetuphike, images)
                        VALUES ( "{$data['hiker']}", "{$data['peak']}", "{$data['meetuphike']}", '{$images}')
SQL;
                }
                $this->grav['sqlite']['db']->exec($sql);
            }
    }
}

#2

Hello finanalyst and thanks for sharing this information. I have a question please:

@finanalyst Can I use sqlite, datatables and perhaps other plugins to create a database web application?, like for instance auto parts, so visitors can search what parts a particular company has, sorted by various criteria like model, year, assembly, etc. If the answer is yes, can it be more difficult or easier than the traditional approach with PHP/MySQL?.
Best regards
joejac


#3
  1. Yes to first part
  2. Second question: I would say easier than working from scratch with PHP/MySQL.
    • Nothing against MySQL / Julia , but I prefer sqlite, as it has the same sort of ‘flat file’ philosophy as GRAV.
    • GRAV gives you from scratch
      • the ability to have users, with / without login & permissions
      • themes for the whole website
      • forms, site admin, etc etc
    • When you say MySQL, do you actually mean the db engine? Or are you referring to the use of MySQL as the driver of a ‘non-flat-file’ CMS? If the latter, that is not a question I will answer as it will be a matter of taste.

#4

Hello finanalyst and thanks for your valuable answer.
So I can use GRAV with SQLite to develop a classical web application that handle data with a relational database. Yes I mean the db engine type application, that loads the server, like the MySQL family of databases, a db application custom made, not related directly to a CMS.

Is good to know that I can use GRAV/SQLite as a flat file development platform for this type of data applications, because so many things are already ready, like you mention, like in a high level framework, and also has the possibility to mix a database application with content via GRAV CMS.

Last question @finanalyst Is there a tutorial on how to develop a simple database application using Grav/SQLite? I would appreciate links.

Thanks a lot for your kind answer.
Best regards
joejac


#5

Hi @joejac,

Re: tutorial

Other than the documentation and this illustration of mine, I do not know of a tutorial that addresses Grav & Sqlite.

Grav has some good documentation, but at a certain point, the documentation stops. There is a fantastic amount of functionality in Grav because it is built on Syfony and other components, but the documentation of these other possibilities is missing. At that point, GRAV developers say ‘go look at the code’. That’s what I had to do when I developed the sqlite, sequential forms, and other plugins.

If you need some extra help, let me know how to contact you directly.

Happy New Year
Regards,
Richard aka finanalyst


#6

Glad I checked back in and saw replies to @finanalyst 's post . My goals is to just set up a high school lacrosse registration site for daughters team ( pre-season registration is a paper work nightmare). My goals aren’t ambitious as @joejac, but coming from a non web developer background, I’m still trying to get my head around the entire Grav infrastucture. It’s not the same as admin’ing the wife’s employer’s DNS and mail servers. That’s just tweaking the configuration and combing through the logs, to see if the goal has been reached! For example, I would like to set up my select field’s pop ups to use a sqlite table (which exists), but no “Dummy’s Guide” code example exists. So, I’m following @finanalyst advice and studying his Sqlite plugin, as well as, Grav’s Database, Views plugins for enlightenment. Still out of my depth, but making progress. Love putzing with Grav, but documentation is aimed at developers! I’d definitely buy a book entitled "Idiot’s guide to the Grav Infastructure (what to do and what not to do!)


#7

Not sure I’m up to writing a book, but I’d be willing to put together a short tutorial on how to integrate Grav with an existing sql database.

If you have a short description of your existing sqlite database (there is a way to get information in a text file about how the database was created (eg. on this tutoral site there is a command .schema that does whats needed), and the sort of form you would like to see, then I could try to put together a tutorial based on that information.

Also, if you look at my sequential-forms plugin, you can see how to collect information, and show videos. I wrote the plugin to solve exactly the sort of problem you have, creating a process for registering clients. The problem is that if you ask for too much information all at once, it frightens people. But if you ask for a few questions on each page, it is easier.


#8

@finanalyst, Thanks, I’ll post DB schema tomorrow. Working a double shift at wage slave job today. I’ll take a look at your sequential form plugin too. Also, I got to mention that your scrollable table shortcode plugin is pretty nice!


#9

Hello and thanks a lot @finanalyst.
I have just finished to install a Centos 7 headless server in a Virtualbox Virtual Machine to test again Grav and 3 other open source projects. I downloaded and have running one of the skeletons with admin plugin, is very nice.

It would be great to study your tutorial :man_student:, so I can test Grav with SQLite, I will study your code on this page, but I am not a programmer although I am curious and I like it, and played with PHP/MySQL for some stuff, several times.

Thanks a lot for you kind help finanalyst, when you have the time and the tutorial, please post the link here.
Best regards
joejac


#10

@finanalyst, What I wanted to do with sqlite is duplication of the LearnGrav site’s cookbook “add-a-custom-select-field” using a table as the popup array. An example would be the lax player’s field position. Following the cookbook recipe, I created a working popup. Now to do it in sql.

sqlite> .schema positions
CREATE TABLE positions (postid integer primary key, position text);
sqlite> select * from positions;
postid position


1 attack
2 defense
3 midfield
4 center
5 goalie

A more interesting example is keeping track of team tryouts and their information the team needs per school/program regs.

sqlite> .schema tryouts
CREATE TABLE tryouts (tryoutid integer primary key, team text, name text, grade integer, age integer, phone text, email text, parent1 text, p1phone text, p1email text, parent2 text, p2phone text, p2email text);

There are more fields to implement. Coding all that info into the theme’s main .php file is troublesome. even if self done. Asking somebody else, possible disaster. To update player info, I would like to present a popup select menu, so that fellow helpers could select a player by name.

Being a newbie end user to Grav, do I attempt a plugin or create my own classes/files and then how to have Grav include them. Any pointers would be appreciated to set the right direction. Hoping tutorial is do-able for you. Thx.


#11

I have written a tutorial based on the schema above.

Please see my new blog.


#12

see new blog post


#13

Is your blog set for public access? Blogger informs me that I don’t have permission to view the page.


#14

I can not see the post neither, I get the message: “your gmail account does not have access to see this page”


#15

Sorry about this. My first blog on that site. Can’t seem to access admin site from Mobile. Will fix when get to a pc.

Not Grav based :kissing_smiling_eyes::rofl:


#16

@jstubbs & @joejac - Sorry guys, I posted the wrong URL - the admin one, not the public facing one. I have edited the URL above, but It is HERE as well. Let me know what you think.
Richard


#17

@finanalyst Nice work! That certainly helped to crystalize my understanding. It’s great to have tutorials that newbies can see how all the pieces fit together. Just finished my first read through. I’ll print it out and read it it again. Of that age, that analog reading is still required! Hope to see more, if you can spare the time. thx.


#18

Thank you for this tutorial, great!


#19

Thanks a lot for your valuable time preparing the tutorial @finanalyst is very nice tutorial, now I can see how convenient and powerful is the concept of the frontmatter in Grav pages.

Me I have a suggestion, when you state that a knowledge required is assumed, that is not going to be explained in your tutorials, please provide when possible, the link to related documentation, for readers that might not have that knowledge, for instance when you wrote:

It is not a good idea to allow anyone accessing the website to have a chance to change data. So any page that has a way of changing data should be password protected. There are several ways to do this in GRAV, so I will not say any more.

A link to the reference that has your recommended way to do this will be very much appreciated.

Very good tutorial indeed.
Thanks and regards
joejac


#20

Thank you for the feedback. GRAV does have some good foundational documentation with the learn.grav.org system. I will edit to point to this source.