Home

GoERP Guides

This document serves as a container of all possible template assembly options.

It mostly answers the questions like:

  • How to display components in UI?
  • How to fill components with the data?
  • How to properly bind input objects to the forms?
  • How to use delete action?
  • And so on.

Deprecation

Following is a list of functions that are deprecated. These should not be used when creating new applications and should be replaced when refactoring existing applications.

Function Description Alternative
Model type data sources All data sources that do not follow the following pattern: .Data.{API}.Api.{Name}…
Except .Data.AutomatApi that is goerp internal api and ErplyApi.ConfigurationList that is composed by goerp
Use dynamic api data sources instead
staticFileLink helper Used to generate full links to assets, this is replaced by .Tools.StaticLink. The old one does not support custom domain links and wasm builds. .Tools.StaticLink

The document separated into following sections:

Subsections of Home

Links

  • New template editor: Goerp Editor Before entering the editor, you must maintain the client code and login.
  • Style library: Goerp Style

Document related definitions

  • Entity - represents API response object. Main responsibility of Goerp is to provide API integrations and tools to display content from those API on UI applications (.gohtml pages). Entities are not usually visible in the template editor, but it is critical to understand the difference between model and entity.
  • Model - represents data definition model. They are view models. By specifying the view model inside the template (in input tag by setting name parameter or in data sets by using .Data notation), we are providing commands to the goerp and based on those commands data would be fetched from dependent API’s. Usually models are generated from the entities and vice versa. However, if entities can be passed to the view “as is” then entities are used as view models (for example most lists for the dropdowns).

UI/UX implementation guides

Subsections of UI/UX implementation guides

Making templates

Subsections of Making templates

Pages

GOERP uses golang templates while assembling the pages, so any options that are supported by go templates may be applied here. Please refer to the official docs to get more information. Pages creation process is like writing html code, including some features from go templating system.

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport"
        content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
</head>
<body>

</body>
</html>

Alternatively, GOERP have in-build layout system, so one layout can be re-used on many pages. All default layouts located in the partials section of the editor. Layouts have suffix -layout.

Here is example for the complex page, using layouts:

{{ template "dev-v2-layout" . }}

{{ define "title-block" }} Title of the tab {{ end }}

{{ define "content-block" }}
All content goes here, basically <main></main> should be in this section
{{ end }}

{{ define "js-block" }}
All js code goes here, <script></script>
{{ end }}

Content partials

Note

This section describes content partials, which have go-html content only. For java-script and css partials check “JS and CSS partials” section.

Partial is a part of document that can be re-used in several pages, which may be convenient if application consists of several pages with same content in some places. Let’s say we have application where navigation content repeated in every page, with partial we can put this content in one template and re-use it in every page. Sounds very convenient, but still they have some restrictions:

  • Partials can also contain unlimited partials, but the maximum depth (nested levels) is currently limited to 5
  • Partials cannot have js and css imports/blocks

Create partial

To create a partial, go to the template editor and pick Create -> Create new template, then define name and select type Partial from the dropdown. Editor will generate very simple initial code for the partial and append suffix -partial to the template name:

{{ define "my-cool-partial" }}
<!-- Feel free to write your awesome component using HTML and powerful templating options -->
{{ end }}

So lets update newly created partial with some content

{{ define "my-cool-partial" }}
    <h1>Hello Goerp!</h1>
{{ end }}

and now let’s inject partial into the page:

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport"
        content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
</head>
<body>
  {{ template "my-cool-partial" . }}
</body>
</html>

Passing data to the partial

In the last section we created simple partial and injected into the page. There is dot in the end of partial injection statement, which means that we are passing all data that was sent from back-end with the response to the partial. In this case we can use any available variable field, for example changing our <h1>Hello Goerp!</h1> to the <h1>Hello Goerp! Client code {{ .Data.Session.ClientCode }}</h1> will print the client code number.

However, in some cases we may want to pass specific set of variables instead of all available ones. In this case we can use in-build function that will produce variable of the key-value pairs (check Built-in helper functions topic mkMap func for more details). So we need to update our partial to use the variable: <h1>Hello Goerp! Client code {{ .clientCode }}</h1> and then pass this variable while injecting the template in our page: {{ template "my-cool-partial" mkMap "clientCode" 123456 }}. Or we can pass any part of available in page data, this will also work: {{ template "my-cool-partial" .Data.Session }} and then <h1>Hello Goerp! Client code {{ .ClientCode }}; session key {{ .SessionKey }}</h1>

JS and CSS partials

Warning

According to the new CSP (Content Security Policy) requirements, all inline css and js content will be blocked by browser.

Samples that would be blocked:

  • <p style="padding: 5px;">blocked</p>
  • <script>console.log("blocked")</script>
  • <header><stile>html {padding:5px;}</style></header>
  • <button onclick="funccall()">blocked</button>

Valid samples:

  • <header><link rel="stylesheet" href="{{ "partial-css" | staticFileLink }}"></header>
  • <header><link rel="stylesheet" href="https://link.to.my.css"></header>
  • <body><script src="{{ staticFileLink "partial-js" }}"></script></body>

To create a css/js partial, go to the template editor and pick Create -> Create new template, then define name and select type JS or CSS respectively. Editor will create empty file and append suffix -css or -js respectively to the template name. Now we can write any valid css/js code there, just like we would do in regular .js or .css files.

Now, when static partial is ready, we can link it with the page like this:

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport"
        content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  
  {{/* Link css partial, as an option, calling linking function in a pipe */}}
  <link rel="stylesheet" href="{{ "partial-css" | staticFileLink }}">
  <title>Document</title>
</head>
<body>
  {{/* Page content */}}

  {{/* Link js partial, as an option, using regular linking function call */}}
  <script src="{{ staticFileLink "partial-js" }}"></script>
</body>
</html>

Java-script and css partials are static files, and they are not part of the goerp template. To link our templates with the static partials (js, css), we can use helper function staticFileLink like this: <link rel="stylesheet" href="{{ "partial-css" | staticFileLink }}">. Or make linking as usual css and js imports: <link rel="stylesheet" href="https://link.to.css.file">

Layouts

GOERP have in-build layout system, so one layout can be re-used on many pages. This feature would be very useful if application have many templates (pages) because with layouts we can encapsulate all general html into one component (e.g. css and js dependencies, general html like header, footer, menu, etc…).

Note

Layouts usage may look similar to the regular partial logic, however, they behave in absolutely different way. The main difference between layout and regular partial is that the former one contains code placeholders that are replaced with actual payload on the related page template, and the latter one doesn’t have any placeholders and contains only code that is related to this specific partial.

In other words:

  • in case of layouts, page template exports go-html content to the layout through block keywords by define‘ing those blocks;
  • in case of partial, page template imports go-html content from the partial through the template notation;
  • both, layout and partial, should be defined inside page by using template keyword, the only difference is that layouts must be defined at very beginning of the page template.
  • both, layout and partial cannot include other partials as dependencies

Under pages topic we already covered briefly layouts feature. Let’s dive into more details now.

Very simple example (layout)

{{ define "simple-layout" }}
<!-- Let's say we want to encapsulate page content that is the same for all pages in our application -->
<!doctype html>

<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">

  <!-- Default Erply styles -->
  <link rel="stylesheet" href="https://assets.erply.com/bo-prototype/_style.css">
  <!-- Maybe some custom styles -->
  <link rel="stylesheet" href="https://assets.my-company.net/style.css">
  <!-- Regular fonts from google -->
  <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">

  <!-- With this block we are creating placeholder which may be filled from the page template -->
  <!-- Here we may inject page specific css links or static file dependencies -->
  <!-- Please note, all block definition names must have -block suffix at the end -->
  {{ block "css-block" . }} {{ end }}

  <!-- Add page specific title -->
  <title>
    {{ block "title-block" . }} Default title {{ end }}
  </title>
</head>
<body>

<menu class="menu-container">
  <!-- This menu will be included into all dependent pages -->
</menu>

{{ block "content-block" . }}
<main>
  <p>
    Another cool feature of the placeholder blocks is that they may contain default content,
    some kind of fall back in case if dependent page decides to leave this block undefined. So,
    if page that is using this layout doesn't have <code>{{ define "content-block" }} {{ end
    }}</code>
    section then <strong>this message shown by default</strong>.
  </p>
</main>
{{ end }}

<!-- Another sample how to pass some data from templates to the js using inputs -->
<input type="hidden" id="AUT_SESSION" value="{{ toJson .Session }}">
<input type="hidden" id="REQUEST_STATS" value="{{ toJson .Data.RequestStats }}">

<!-- global js dependencies -->
<script src="/assets/js/automat.deps.js" type="application/javascript"></script>
<script src="/assets/js/menu.bundle.js" type="application/javascript"></script>

<!-- placeholder for js dependencies, use static files to import js code -->
{{ block "js-block" . }} {{ end }}

</body>
</html>
{{ end }}

First of all, we are defining name of the layout, which must end with layout suffix. Don’t worry, suffix will be added automatically when creating new template from editor and selecting layout template type. So, for our sample during layout creation we enter simple name and -layout added by default.

Next, we have some regular page content that is, potentially, repeated on every page in our application.

The first block that would be replaced by content from the page that will use this layout is {{ block "css-block" . }} {{ end }}. Then we have title placeholder: {{ block "title-block" . }} Default title {{ end }} and then placeholder for the biggest part of the page, - main section: {{ block "content-block" . }} {{ end }}. Check default (fall back) content of this block in the sample. Finally, we have js placeholder where we can include any js related to the page, please refer to css and js partials for more information on how to include js and css code.

Warning

While defining blocks inside layout, always put -block suffix in names. If name will end with something else then goerp parser will process them as regular partials and mess up template parameters. This may lead to appear some unknown partials and mey produce unexpected behavior.

Very simple example (page)

Now, when we have defined the layout, we may want to use it inside our pages. Let’s say it 10th page of our application:

<!-- Page creation always starting from importing our layout -->
{{ template "simple-layout" . }}

<!-- Title for our 10th page -->
{{ define "title-block" }} 10th page of application {{ end }}

<!-- The biggest part, - content -->
{{ define "content-block" }}

<!-- Display errors -->
{{ range .Data.Errors }}
<div class="error-row">
  <span>{{ . }}</span>
</div>
{{ end }}

<!-- Include partials -->
{{ template "employee-query-form-partial" . }}

<!-- Write regular goerp template code -->
<table>
  <thead>
  <tr>
    <th>Id</th>
    <th>First Name</th>
    <th>Last Name</th>
  </tr>
  </thead>
  <tbody>
  {{ range .Data.AccountAdminApi.EmployeeList }}
  <tr>
    <td>{{ .Id }}</td>
    <td>{{ .FirstName }}</td>
    <td>{{ .LastName }}</td>
  </tr>
  {{ end }}
  </tbody>
</table>

<!-- Include js -->
{{ define "js-block" }}
<script src="{{ staticFileLink "my-cool-js" }}"></script>
{{ end }}

{{ end }}

First of all, we are importing layout to the page.

Tip

Notice dot at the end of layout definition? Yes, we can pass any data to the layout, with the dot we are passing everything that page have. However, we can pass some custom data and, for example, add tabs logic to the layout. Just a tip.

Next, we are defining title of our page with {{ define "title-block" }} 10th page of application {{ end }}. Next, in our template we have css-block, but we don’t need to include any css links/deps here, so we just skip this block. Next goes content of the page, the biggest part. There we can use other templates-partials and goerp templating logic.

Finally, we have js block implementation which includes static file.

When to use layouts?

Layouts are powerful feature of the goerp template engine. However, it is quite complex and may produce some wierd behaviour if it is used in a wrong way. Better to avoid using layouts if they are not simplifying your work, just follow KISS principles. Same advice regarding partials. For example, if your application have 3 pages with simple form and a couple of tables, then maybe would be more convenient to write all content inside one template-page, so don’t need to jump between partials during development or maintaining.

Here is a short tips list when to use layout and when to avoid it:

Layouts are handy when

  • My application have many pages with repeated content (such as menu, tabs, dependencies, etc.)
  • I have many applications that reuses same code on every page. Please note, in this case you still need to create duplicate layout and give it a new name, but you need just copy-paste the content.

Avoid layouts when

  • My application have few pages and duplicating similar content wouldn’t take much effort

Pagination

To implement pagination, query parameters are usually used, which indicate how much data should be returned on the current page, how many records per page should be displayed, and what page number should be shown. The most commonly used query parameters for pagination are:

Page or PageNo: current page number (starting from 1). RecordsOnPage: number of elements per page (usually used to limit the amount of data returned per page).

For example, a request to get the first 20 items of a product listing on the second page might look like this:

GET /products?page=2&RecordsOnPage=20
Note

NOTE For pagination to work it is important to place it inside the GET form and add the pagination data.

Pagination example

This code is for a pagination component that displays and navigates through a table of data with 20 records per page by default. It includes previous and next page buttons, a dropdown menu for selecting the number of records to display per page, and a button to update the table based on the selected number of records.

<form method="get" data-pagination>
<!-- Pagination interface -->
<div class="pagination aligner aligner--contentStart aligner--centerVertical">
    <!-- Previous page button -->
    <button class="button--icon button--outlined icon-Chevron-Left"  id="previous-button"></button>

    <!-- Current page number -->
    <input type="hidden" name="Data.Example.PageNo"  id="page" value="{{ .Data.Example.PageNo }}">
    <p class="aligner aligner--centerVertical"> Page {{ .Data.Example.PageNo }} </p>

    <!-- Next page button -->
    <button class="button--icon  button--outlined icon-Chevron-Right"  id="next-button"></button>

    <!-- Results per page dropdown -->
    <p class="aligner aligner--centerVertical">Results per page</p>
    <select class="select margin-left-16 aligner aligner--centerVertical" name="Data.Example.RecordsOnPage" id="select">
        <option value="20"  {{if eq "20"  $.Data.Example.RecordsOnPage}}selected{{end}}>20</option>
        <option value="50" {{if eq "50"  $.Data.Example.RecordsOnPage}}selected{{end}}>50</option>
        <option value="100"  {{if eq "100"  $.Data.Example.RecordsOnPage}}selected{{end}}>100</option>
    </select>

    <!-- Show button to submit selected number of results per page -->
    <button  id="show"  class="button button--primary">Show</button>
</div>
</form>

Subsections of HTML layouts, components, etc.

Error and success states

Goerp server returns errors with every response. Errors are available in .Data.Errors variable which is array of strings. So it is possible to go through this array and display errors on page.

Adding small block into the page (or into layout) may look like that:

{{ range .Data.Errors }}
<div class="error-row">
  <span>{{ . }}</span>
</div>
{{ end }}

In addition to errors, response contains success flag which is available only after posting form with POST action and may be found in .Data.FormControl.PostActionSuccessful. So, right after errors block may be reasonable to add success message as well.

{{ if .Data.FormControl.PostActionSuccessful }}
<div class="success-row">
  <span>Success!</span>
</div>
{{ end }}
Note

NOTE When you add the errors and success flag, you are not writing any flags. Goerp editor will check errors and success flag by itself.

Form inputs

Subsections of Form inputs

Validation

The validation script is already embedded inside the script bundle.js

<script src="https://assets.erply.com/bo-prototype/js/bo-prototype.bundle.js"></script> 

In order for the field to be important, it is enough to write in the required in input

<div id="Mobile-error">
  <input type="text" required class="input input-fullWidth"
         id="Mobile" name="Mobile"
         placeholder="(e.g., +1 800 555 5555)"

  <!--It is important to write pattern parametrs-->
  pattern="[\d+\- ()]*">

  <!--It is important to write the notification field in-->
  <p class="text-error text-small" id="Mobile-text" style="display: none;">
    Required and should only contain numbers, plus, dashes, and spaces</p>
</div>

Ready patterns For email

pattern="^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"

This pattern checks that an email address starts with one or more alphanumeric characters, which can be followed by any number of dots, hyphens, plus signs, and percent signs. This is followed by the @ symbol followed by the domain name For zipcode

pattern="^\d{5}(?:[-\s]\d{4})?$"

This pattern checks that the Zipcode meets the following criteria: Consists of 5 digits For phone

pattern="[\d+\- ()]*"

Ready patterns Types For string

pattern="^[a-zA-Z\s]+$"

For numbers

pattern="^?\d+$"

Checkbox

The value of the checkbox is set based on the checkbox state. If checked, then it is true, otherwise false. Please note that input should have form-input class.

<input type="checkbox" id="formInputOnlineAppointmentsEnabled" class="form-input"
       name="AccountAdminApi.WarehouseInput.OnlineAppointmentsEnabled"
       {{ if .Data.AccountAdminApi.WarehouseInput.OnlineAppointmentsEnabled }} checked {{ end }}>

<!-- if not checked, passed hidden field, otherwise both but first one have higher priority (FIFO) -->
<input type="hidden" name="AccountAdminApi.WarehouseInput.OnlineAppointmentsEnabled" value="false">
<label for="formInputOnlineAppointmentsEnabled">Online appointments</label>

Public pages

Introduction

This option can either create completely public pages or pages that can be accessed via registration. On the pages that require registration the user accounts are created as customers.

Permission

Public pages are protected by 3 different methods.

  1. Erply user group permissions - on installation two user groups per app are created app-public-{app_name} and app-b2b-{app_name}, all data fetches to erply api’s will use the rights on these groups.
  2. Dynamic api request whitelist - a list of calls that are allowed with the dynamic api feature.
  3. Parameter whitelist - a list of parameters that we allow to be used.

Dynamic api request whitelist

The whitelist is under the ‘Publish settings’ tab in the editor view.

Publish settings Publish settings

When enabled the dynamic requests would need to be added to the page

Request whitelist Request whitelist

As of 1.234.5+ disabling of these features is not recommended and will be disabled for new templates.

Parameters whitelist

Note

This feature used to be connected to the Alias list (v1.188.5), from v1.189.0 this is now separated from it.

The following error is given when on public pages we send a parameter that is not added to the whitelist.

Parameter whitelist error Parameter whitelist error

The whitelist can be accessed under the ‘Publish settings’ in the editor view. The parameter whitelist counts in presets and request the whitelist (when used) so the values there do not need to be duplicated here.

Publish settings Publish settings

When enabled the used parameters need to be filled to the list of the page.

Parameter whitelist Parameter whitelist

If the permissions were previously set to the aliases then we can also use the ‘Import from alias’ button to automatically fill them based on the alias settings.

As of 1.234.5+ disabling of these features is not recommended and will be disabled for new templates.

Pattern matching rules for parameter whitelist

The whitelist supports pattern matching for the parameters. That would be very helpful when working with the json that contains or generates arrays. At the moment pattern matching is supported for digits <%d%> and string <%s%>.

To define a placeholder just put a <%d%> in the place where actual parameter name may have a number. Can be defined as many placeholders per parameter as needed.

For example, this rule:

KvsApi.Api.Json.saveData.string.entries.<%d%>.value.products.<%d%>.code

will much any of the following parameters:

KvsApi.Api.Json.saveData.string.entries.1.value.products.1.code
KvsApi.Api.Json.saveData.string.entries.2.value.products.1.code
KvsApi.Api.Json.saveData.string.entries.1.value.products.2.code
KvsApi.Api.Json.saveData.string.entries.2.value.products.2.code

Subsections of Public pages

Protected

Introduction

Protected pages can only be accessed with a special customer session

Setup

In order to use the protected features we need a page that that is set up to allow B2B access.

This can be configured in the template editor mode under the ‘Publish setting’ right menu.

Note

As of version 1.248.0 its also possible to set an optional expiration date to the page (UTC unix value) after this timestamp has passed on the server the will no longer be accessible using the b2b endpoint. Use authentication config input set the value.

Also note that this setting is based on applications or auth domains.

Add protected configuration Add protected configuration

  1. Every customer that is registered can only access the same application, the same login cannot be used for another app on the account.
  2. If auth domain is provided then all the applications that share it will also share the customers.

Optionally the redirect field can be used to redirect requests that are not authenticated to a specific page (public login page for example).

Registration

Create a public page that implements the automat api’s registration form. Use the Session.Customer.ID to detect if the user is already logged in.

Optionally, the AutomatApi.B2BLoginRegisterInput.LoginOnSuccess can be used to automatically login newly created user and AutomatApi.B2BLoginRegisterInput.Redirect to redirect the user to a specific page after successful login.

Check the automat api B2BLoginRegisterInput data source docs for additional available fields.

<!-- Read registration errors -->
<div class="my-error-container">
    {{ range .Data.Errors }}
    <span class="my-error-message">{{ . }}</span>
    {{ end }}
</div>

{{ if .Session.Customer.ID }}
    <h1>Already registered</h1>
{{ else }}
    <h1>Register a new user</h1>
    <form method="post">
        <input type="hidden" name="postActionEntity" value="B2BLoginRegisterInput">
        <input type="hidden" name="postActionRedirect" value="b2b-login-demo-page">

        <!-- Configure autologin and redirect -->
        <input type="hidden" name="AutomatApi.B2BLoginRegisterInput.LoginOnSuccess" value="1">
        <input type="hidden" name="AutomatApi.B2BLoginRegisterInput.Redirect" value="b2b-2-members-page">

        <br>
        <label for="firstName">First name</label>
        <input type="text" id="firstName" name="AutomatApi.B2BLoginRegisterInput.Firstname">

        <br>
        <label for="lastName">Last name</label>
        <input type="text" id="lastName" name="AutomatApi.B2BLoginRegisterInput.Lastname">

        <br>
        <label for="email">Email</label>
        <input type="text" id="email" name="AutomatApi.B2BLoginRegisterInput.Username">

        <br>
        <label for="password">Password</label>
        <input type="password" id="password" name="AutomatApi.B2BLoginRegisterInput.Password">

        <br>
        <button type="submit">Register</button>
    </form>
{{ end }}

Login

Create a public page that implements the automat api’s login form. Use the Session.Customer.ID to detect if the user is already logged in.

Optionally the AutomatApi.B2BLoginInput.Redirect can be used to redirect the user to a specific page after successful login

<!-- Read login errors -->
<div class="my-error-container">
    {{ range .Data.Errors }}
    <span class="my-error-message">{{ . }}</span>
    {{ end }}
</div>

{{ if .Session.Customer.ID }}
    <p>Welcome {{ .Session.Customer.FirstName }}</p>
{{ else }}
    <form method="post">
        <label for="username">Email</label>
        <input type="text" id="username" name="AutomatApi.B2BLoginInput.Username"/>

        <br>

        <label for="password">Password</label>
        <input type="password" id="password" name="AutomatApi.B2BLoginInput.Password"/>

        <br>
        <button type="submit">Login</button>
    </form>
{{ end }}

Public

Introduction

Public pages are accessible to all users.

Setup

This can be added in the template edit view and enabling the “Allow B2B and public access” setting.

Note

As of version 1.248.0 its also possible to set an optional expiration date to the page (UTC unix value) after this timestamp has passed on the server the will no longer be accessible using the public endpoint. Use authentication config input set the value.

Add public configuration Add public configuration

These routes can be accessed by prefixing the routes with /public/

Example:

  1. automat-eu.erply.com/104146/en/my-test-page
  2. automat-eu.erply.com/public/104146/en/my-test-page

Using dynamics

Starting from version 1.246.0, GoErp allows to use dynamic api features while creating register, login and others b2b authentication calls. This allows to create multiple requests and chain data between them, including authorization calls.

Note

Those API calls works properly only inside GoErp templates, because b2b (public) authentication depends on the template configuration, therefore all b2b API endpoints requires b2bKey to be sent in the request header. This key is available inside session and could be chained to the API request header.

More about b2b API calls read in the Automat API documentation.

Registration page

A simple registration page using API and dynamics. All page configuration steps remains same as for model based b2b authentication.

<h1>Register page</h1>
<form method="post">
   <input type="hidden" name="AutomatApi.Api.Post.register" value="v1/b2b/register-user">
   <!-- Use chaining to pass b2b key to the header -->
   <input type="hidden" name="AutomatApi.Api.Header.register.<-b2bKey" value="Session.key">
   <!-- Use .Tools.B2bAuthDomain to get the domain -->
   <input type="hidden" name="AutomatApi.Api.Json.register.string.domain" value="{{ .Tools.B2bAuthDomain }}">
   <!--  Setup redirect on succeed, if needed. It will be triggered only if all calls in this 
   template are successful. So, if registration fails, we stay on this page, very useful. -->
   <input type="hidden" name="Form.Redirect" value="10-b2b-in-page">
   
   <fieldset>
      <label for="firstname">Firstname:</label>
      <input id="firstname" name="AutomatApi.Api.Json.register.string.firstname" value="">
      <label for="lastname">Lastname:</label>
      <input id="lastname" name="AutomatApi.Api.Json.register.string.lastname" value="">
   </fieldset>
   <fieldset>
      <label for="username">Username:</label>
      <input id="username" name="AutomatApi.Api.Json.register.string.username" value="">
      <label for="password">Password:</label>
      <input id="password" name="AutomatApi.Api.Json.register.string.password" value="">
   </fieldset>
    <!-- Login would be performed automatically only if this parameter set to 1 -->
   <input type="hidden" name="AutomatApi.Api.Json.register.string.triggerLoginAfterRegister" value="1">
   <button type="submit">Register</button>
</form>
Note

Please note that if the triggerLoginAfterRegister parameter is not passed or set to 0, the user will be registered but not logged in. Although, Form.Redirect will be triggered anyway and try to access the protected area (10-b2b-in-page in this case). Therefore, while user not logged in, the 10-b2b-in-page page configuration will redirect to the login page (if configured). So, this could be confusing.

Login page

Here we will just use the username and password, and redirect to the protected area when successful.

<h1>Login page</h1>
<form method="post">
   <input type="hidden" name="AutomatApi.Api.Post.login" value="v1/b2b/login">
   <!-- Use chaining to pass b2b key to the header -->
   <input type="hidden" name="AutomatApi.Api.Header.login.<-b2bKey" value="Session.key">
   <!-- Use .Tools.B2bAuthDomain to get the domain -->
   <input type="hidden" name="AutomatApi.Api.Json.login.string.domain" value="{{ .Tools.B2bAuthDomain }}">
   <!--  Setup redirect on succeed, if needed. It will be triggered only if all calls in this 
   template are successful. So, if login fails, we stay on this page, very useful. -->
   <input type="hidden" name="Form.Redirect" value="10-b2b-in-page">
   <fieldset>
      <label for="username">Username:</label>
      <input id="username" name="AutomatApi.Api.Json.login.string.username" value="">
      <label for="password">Password:</label>
      <input id="password" name="AutomatApi.Api.Json.login.string.password" value="">
   </fieldset>
   <button type="submit">Login</button>
</form>

Samples

In the following sample we have a registration, login and a members area. This shows how we restrict the members area to only read results for the logged in customer.

Page setup

Note

The public features only work if the registration, login and any members pages belong to the same application or auth domain.

Registration page

A simple registration page with minimal inputs.

<!DOCTYPE html>
<html>
<body>
    <h2>Register a new user</h2>
    <form method="post">
        <input type="hidden" name="postActionEntity" value="B2BLoginRegisterInput">
        <input type="hidden" name="postActionRedirect" value="b2b-2-login-page">

        <div class="user-box">
            <input type="text" name="AutomatApi.B2BLoginRegisterInput.Firstname" required="">
            <label>Firstname</label>
        </div>
        <div class="user-box">
            <input type="text" name="AutomatApi.B2BLoginRegisterInput.Lastname" required="">
            <label>Lastname</label>
        </div>
        <div class="user-box">
            <input type="text" name="AutomatApi.B2BLoginRegisterInput.Username" required="">
            <label>Email</label>
        </div>
        <div class="user-box">
            <input type="password" name="AutomatApi.B2BLoginRegisterInput.Password" required="">
            <label>Password</label>
        </div>

        <!-- Read possible registration errors -->
        <div>
            {{ range .Data.Errors }}
                <span>{{ . }}</span>
            {{ end }}
        </div>

        <button type="submit">Register</button>
    </form>
</body>
</html>

For publish settings we enable the ‘Allow B2B and public access’ checkbox

Also since registration allows more fields to be used we also need to fill in the parameter whitelist of the value we will allow to be used, in this case we will fill the ones we have defined in the form.

Add public configuration Add public configuration

Login page

Here we will just use the username and password, and redirect to the members area when successful.

<!DOCTYPE html>
<html>
<body>
    {{ if .Session.Customer.ID }}
        <h2>Already logged on!</h2>
    {{ else }}
    <h2>Login</h2>
    <form method="post">
        <input type="hidden" name="AutomatApi.B2BLoginInput.Redirect" value="b2b-2-members-page">

        <div class="user-box">
            <input type="text" name="AutomatApi.B2BLoginInput.Username" required="">
            <label>Username</label>
        </div>
        <div class="user-box">
            <input type="password" name="AutomatApi.B2BLoginInput.Password" required="">
            <label>Password</label>
        </div>
        
        <div class="my-error-container">
            {{ range .Data.Errors }}
            <span class="my-error-message">{{ . }}</span>
            {{ end }}
        </div>
        
        <button type="submit">Login</button>
    </form>
    {{ end }}
</body>
</html>

For publish settings we enable the ‘Allow B2B and public access’ checkbox.

Login model parameters are automatically whitelisted, so we should not need to fill them here.

Members page

On the members page we will generate a simple list of documents for the currently logged on member. We use the preset to read session id to the request and prevent it from being adjusted via any parameters.

<!DOCTYPE html>
<html>
<body>
    <h1>Members page</h1>
    <form method="post">
        <input type="hidden" name="AutomatApi.B2BLogoutInput.Logout" value="1">
        <button type="submit">Logout</button>
    </form>
    <h2>Welcome {{ .Session.Customer.FirstName }} to the members area</h2>

    <!-- Erply api response data in the 'records' field -->
    <h2>My orders</h2>
    <form method="POST">
        <!-- Request definition -->
        <input type="hidden" name="ErplyApi.Api.Post.getDocs" value="getSalesDocuments" data-preset-val="getSalesDocuments">
        <button type="submit">Reload orders</button>
    </form>

    <ul>
        {{ $salesDocs := (.Data.ErplyApi.Api.Requests.getDocs.Response.Get "records").Array }}
    
        {{ if $salesDocs }}
            {{ range $salesDocs }}
            <li>{{ .Get "id" }} / {{ .Get "type" }} / {{ .Get "clientName" }}</li>
            {{ end }}
        {{ else }}
            <li>You currently have no orders!</li>
        {{ end }}
    </ul>
</body>
</html>
Note

By default all public access groups do not have access to read documents. This right needs to be given under the public user groups (starting with ‘app_public’ and ‘app_b2b’) by the account administrator in the backoffice.

Every application or authentication domain will have a separate user group with rights assigned to them.

For publish settings we enable the ‘Allow B2B access’ checkbox as we will only want logged in members to access it.

We also set the redirection to the name of the login page, so whenever its being accessed without a proper session it will be automatically redirected.

Add permissions Add permissions

We are also using dynamic api here, so we will add the ErplyApi.Api.Post.getDocs -> getSalesDocuments to the request whitelist.

Under URl configuration we add the preset ErplyApi.Api.PostParam.getDocs.<-clientID : Session.customer.ID This will write the current session customer id to the request when it is being done, since we do not allow the parameter to be adjusted in the parameters list then it cannot be changed to anything else.

Add preset chain Add preset chain

Workflow

To test it:

  1. Register a new user
  2. Login with the created customer
  3. The members area only displays the members sales documents (use backoffice to create them or create a new page that creates the documents for the member using the same method)

Url alias

Introduction

Url alias features allow as to generate better url’s for the application. Using of dynamic api can generate a large url parameters set that cannot be easily read.

Alias feature adds the following functionalities:

  1. Custom path parameters that can be used on any page
  2. Alias mapping that can be used to map parameter values to other parameters
  3. Single parameter can be assigned to multiple values so multiple api calls would not require a separate parameter if the value is the same.

Custom path parameters

Each route can use up to 3 custom parameters (path1, path2 and path3).

If we had a page accessible from /my-store. We can navigate to the page also with /my-store/something_1 , /my-store/something_1/something_1 and /my-store/something_1/something_2/something_3

To read the values we can use regular custom parameters fetch.

{{ .Data.Parameters.path1 }}
{{ .Data.Parameters.path2 }}
{{ .Data.Parameters.path3 }}

Alias mapping

Aliases can be added from the url configuration menu

URL configuration URL configuration

Parameter name would be the alias (some parameter) whose value would be moved to the configured parameters.

If we had a request with a custom parameter /my-store?productId=100 then we can use the alias to set value for the dynamic input.

productId -> ErplyApi.Api.PostParam.productsRq.productID

Add alias Add alias

this would mean that we can load the product api call with the custom parameter, and we would not need to use the dynamic api parameter declaration syntax in the url as it would be aliased to the correct value.

Note that the same parameter can be mapped to multiple parameters so if there are multiple api calls that expect the same value then we can use this to just use one in the url.

Path to alias

We can also map path parameters to the aliases. For this just use the one of the path parameters (path1, path2 or path3).

path1 -> ErplyApi.Api.PostParam.productsRq.productID

This would mean that we can navigate to the page by just /my-store/101 and this would map the value of 101 to the ErplyApi.Api.PostParam.productsRq.productID parameter.

Content types

Introduction

With inbuilt methods its possible to make the server render page content data with different content type header or even produce a file stream for the content.

For this to work we would need to construct the data in the template in correct format we expect to return.

Methods to return

There are 2 ways we can make these calls

  1. Using the appropriate query parameters
/my-csv-page?CSV=1
  1. Using file type suffix
/my-csv-page.csv

Note that -page suffix is not required, a link without it will lead to the correct route.

/my-csv.csv

Supported content types

Type Parameter Path suffix
Pdf ?PDF .pdf
Xml ?XML .xml
Csv ?CSV .csv
Json ?JSON .json
Txt ?TXT .txt

Additional parameters

Pdf

  1. PDF.FileName - Server will produce the data as a file stream instead, giving the file the requested name
  2. PDF.DPI - Dpi of the pdf, default 300
  3. PDF.Orientation - Orientation, default Portrait (Portrait, Landscape)
  4. DF.PageSize - File size, default A4 (A0, A1, A2, A3, A4, A5, A6, A7, Letter, Legal, Ledger, Tabloid, Folio, Executive)
  5. PDF.MarginTopMM - Set a top margin
  6. PDF.MarginBottomMM - Set a bottom margin
  7. PDF.MarginLeftMM - Set a left margin
  8. PDF.MarginRightMM - Set a right margin
  9. PDF.PageWidth - Set a custom page width (this will override the page size value)
  10. PDF.PageHeight - Set a custom page height (this will override the page size value)

Xml

  1. XML.FileName - Server will produce the data as a file stream instead, giving the file the requested name

Csv

  1. CSV.FileName - Server will produce the data as a file stream instead, giving the file the requested name

Json

  1. JSON.FileName - Server will produce the data as a file stream instead, giving the file the requested name

Txt

  1. TXT.FileName - Server will produce the data as a file stream instead, giving the file the requested name

Setting content type in the template

This will apply same rules as setting the content type in the url, but in this case entire template would be processed using most suited template processor (not related to PDF). For example, setting content type to json or xml would process template through text processor, not html, removes all html related validations/encoding and makes process much faster.

Content type setting in template Content type setting in template

Navigation

Introduction

Only page, css and js types can be accessed with the url. Regular implementation url’s in the editor would look like this

{instance}/{clientCode}/en/editor/my-products-page

This would be accessible from

{instance}/{clientCode}/en/my-products-page

Note that -page suffix is not required and a link without it would still be routing to the correct location

{instance}/{clientCode}/en/my-products

Public / B2B

If the public features are used then the endpoint would prefix it with public

{instance}/public/{clientCode}/en/my-products

Sub paths for navigation

We can use the sub paths for navigation inside the templates.

Usually 3 extra sub paths can be used. We can access the path values with an indexed value from the path.

https://template-engine-eu10.erply.com/104706/en/test-food-ordering-choose-table-page/test1/test2/test3

We can then access the values using

{{ .Data.Parameters.path1 }} // test1
{{ .Data.Parameters.path2 }} // test2
{{ .Data.Parameters.path3 }} // test3

Goerp has a special parameter that can be used to get the last path parameter as well (all content after the last non-encoded /) https://template-engine-eu10.erply.com/public/104146/et/some-page/test1/test2/test3/test4%2Ftest5

{{ .Data.Parameters.pathx }} // test4%2Ftest5

These routes will always navigate to the parent page, but we can set up special rules in the page to make some changes based on the path parameters.

{{ if eq .Data.Parameters.path1 "test1" }}
    {{ template "my-route-a-partial" . }}
{{ else if  eq .Data.Parameters.path1 "test2" }}
    {{ template "my-route-b-partial" . }}
{{ else }} 
    {{ template "my-default-partial" . }}
{{ end }}

We can use the tools helper to make navigating sub paths easier

{{ if .Tools.IsPath "my-route-a" }}
    {{ template "my-route-a-partial" . }}
{{ else .Tools.IsPath "my-route-a/my-route-b" }}
    {{ template "my-route-b-partial" . }}
{{ else .Tools.IsPath "my-route-a/my-route-b/my-route-c" }}
    {{ template "my-route-c-partial" . }}
{{ else }} 
    {{ template "my-default-partial" . }}
{{ end }}

Subsections of Navigation

Sitemap

To produce a sitemap we can use the combination of multiple features

  1. The .xml document type suffix
  2. Application url configurations
  3. Automat api helper to produce us a list of possible pages

Create the file

Create a new page type and make it publicly accessible.

Under url configuration lets fill a static preset for the automat api for the application we want to produce the list for.

Name: AutomatApi.Api.Get.myRequest1 Value: v1/application-sitemap/{My_app_uuid_here}

Also add the name and value pair to the request whitelist.

Add the following content to the page

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
    {{ $records := .Data.AutomatApi.Api.Requests.myRequest1.Response.Get "records" }}
    {{ range $records.Array }}
    <url>
        <loc>{{ .Get "loc" }}</loc>
        <lastmod>{{ .Get "lastMod" }}</lastmod>
    </url>
    {{ end }}
</urlset>

This will produce the sitemap file for us when accessing it from the approriate endpoint with the correct document type suffix.

{instance}/{clientCode}/en/editor/my-sitemap.xml

You can also manually produce and edit as you like.

Mapping sub paths

If some pages are using the sub paths features then we can use the url configuration sub paths area to map them. This will make the automat api return all mapped sub paths to the sitemap.

We can use the hide checkbox there to make the page not be generated into the sitemap aswell.

Robots

We can use the txt type renderer to output the robots.txt file. Note that this is only useful for instances that implement a custom domain, as the robots file needs to be at the root of the page.

URL configuration URL configuration

A basic robots file would have the following content and would be called using the .txt document type suffix.

{my-custom-domain}/robots.txt
ser-agent: Googlebot
Disallow: /nogooglebot/

User-agent: *
Allow: /

Sitemap: http://{my-instance}/public/104146/en/sitemap.xml

Back office integration

Linking from GoERP to a back office page

Back office URLs have a predictable structure. Here is a sample URL (a product card):

https://t10.erply.com/12345/?lang=eng&section=product&edit=23
  • The base URL (https://t10.erply.com/12345/) is available as variable {{ .Session.User.BOLoginUrl }}.
  • Parameter lang is the current language. Use {{ .Session.Language.LegacyCode }}. Please always set this parameter; if the user has switched to a particular language, they want this language to be remembered as they navigate through the system.
  • Parameter section identifies the page: the form or the list view.
  • Parameter edit is used on forms and indicates the ID of the record. If you want to open a new empty form, use edit=new.

Prefilling back office forms

On an empty form, any field can be prefilled with a URL parameter. Use the form field’s name attribute as the URL parameter name.

The following URL opens a new invoice form. (For clarity, it has been split into multiple lines.)

  • The document’s type will be set to “Receipt” (ID = 2),
  • customer ID will be set to 99
  • and warehouse ID to 2.
{{ .Session.User.BOLoginUrl }}
?lang={{ .Session.Language.LegacyCode }}
&section=invoice
&edit=new
&invoice_type_id=2
&invoice_orgper_idDat_client=99
&invoice_warehouse_id=2

This approach only works for an empty form, not for saved records.

Prefilling back office list filters

Use this approach if a GoERP page must link to a legacy back office list view.

Erply back office list views use method="post" forms, so filter presets need to be supplied as POST parameters.

For example, opening the invoices page with the “Creator” filter applied (invoices created by employee with ID 123) requires submitting a form:

<form method="post" action="{{ .Session.User.BOLoginUrl }}?lang={{ .Session.Language.LegacyCode }}&section=invoices">
    <input type="hidden" name="search_orgper_idDat_author" value="123">
</form>

Replacing a back office page with a GoERP page

Back office forms can be configured to redirect to other URLs.

If a customer installs an app that manages products, they can make a product card always open in that app. (And likewise, employee forms can open in an employee app, and customer cards in a CRM app).

At the moment this is a manual CAFA configuration step that must be done individually on each account.

  1. Go to Settings > Configuration Admin > App configuration.
  2. Click “Add new configuration” to create a new setting.
  3. Fill in the form as follows:
Field Value
Application bo_ui
Level Company
Level ID leave empty
Type ui_replacements
Name Use any value, for example the name of the app that is going to handle the redirects
Value Type JSON
Value Define a JSON object as instructed below.

Example (shown with all possible supported adjustments, all components optional):

{
  "redirect_creation_form": [
    {
      "section": "prodin",
      "url": "https://example.com/{CLIENT_CODE}/new-inventory-registration-page"
    }
  ],
  "redirect_edit_form": [
    {
      "section": "product",
      "url": "{GOERP_URL}/{CLIENT_CODE}/{ISO_LANGUAGE}/edit-product-page?productID={RECORD_ID}"
    }
  ],
  "redirect_page": [
    {
      "section": "products",
      "url": "https://example.com/{CLIENT_CODE}/product-list"
    }
  ],
  "add_tabs": [
    {
      "section": "orgperC",
      "name": "metadata",
      "title": {
        "en": "Metadata",
        "fr": "Métadonnées"
      },
      "url": "{GOERP_URL}/{CLIENT_CODE}/{ISO_LANGUAGE}/customer-metadata-page?customerID={RECORD_ID}"
    }
  ],
  "replace_tabs": [
    {
      "section": "orgperC",
      "name": "contracts_new",
      "title": {
        "en": "A better tab for customer contracts"
      },
      "url": "{GOERP_URL}/{CLIENT_CODE}/{ISO_LANGUAGE}/customer-contracts-page?customerID={RECORD_ID}",
      "replace": "orgperC_contracts"
    }
  ]
}

Erply section name can be found from the URL: it’s the keyword following “&section=…” Product card is product, inventory registration form is prodin, employee form is orgperB and so on.

The redirect URL supports placeholders:

  1. {GOERP_URL} - Base URL of Erply app store apps.
  2. {CLIENT_CODE} - Account number
  3. {RECORD_ID} - Record ID
  4. {LANGUAGE} - Three-letter language code (used in Erply back office)
  5. {ISO_LANGUAGE} - Two-letter language code (used in app store apps)

As many JSON objects can be created as needed.

Cache

Introduction

Cache features are optional features that can be used to make the page loads quite a bit faster. These features mean that api requests are not always made and the created content is loaded from cache instead.

GoErp implements 2 different caching options. The options are disabled by default as it can affect the behaviour of the pages quite a bit.

The cache settings are only available for page type templates. The settings area can be found under the ‘Publish settings’ area in the template edit view.

Cache settings Cache settings

Browser cache headers

Cache is separate for every client.

This feature utilizes the default browsers cache mechanism using the modified date values and cache max-age modifiers. With this feature the browser caches the contents and sends a request with the modified date value on the next request, the server will respond with not modified if the value has not changed.

Note that the modified value in this case it the date when the page template was updated, any changes from used api’s in the template do not update the value.

Can be used to speed up templates that do not always need the most up-to-date values. You can specify the max-age in seconds. Content will be re-loaded if the max-age is reached or the template page is updated.

GoErp server cache

Single cached element for all clients.

The feature caches the contents on the server instead and returns the data without running the api requests. The cached data is shared between multiple requests.

Should only be used on pure static pages or pages that serve non-current session related data.

A custom age value can be provided in minutes.

Use cases

  1. Mostly static data pages.
  2. Pages that run slow running api requests or api mock pages where fresh by the second data is not always required.
  3. Browser cache headers with a small age can also be used to make a false sense of speed or reduce possible flickering, but note if the data indeed changes it can appear jumpy depending on the layout of the page.

Domain setup for cloudflare

Internal configuration

Access cloudflare dashboard at https://dash.cloudflare.com/ Select add site as illustrated in the below picture

select select

Enter the name of the domain you wish to add eg example.com

Select a plan for the domain

select select

Next you will see which DNS records will be imported automatically

select select

Press next once configured

Now you will see the nameservers that have to be configured in the domain registrar

select select

External configuration

Client will receive nameservers they must point their domain towards For example

liz.ns.cloudflare.com ram.ns.cloudflare.com

This process varies from registrar to registrar Example instructions based on registrar

https://www.ionos.com/help/domains/using-your-own-name-servers/using-your-own-name-servers-for-a-domain/ https://help.101domain.com/kb/managing-name-server-records https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/domain-name-servers-glue-records.html#domain-name-servers-glue-records-adding-changing https://help.blacknight.com/hc/en-us/articles/212512229-Changing-nameservers-in-cp-blacknight-com https://www.bluehost.com/help/article/custom-nameservers https://directnic.com/knowledge/article/33:how%2Bdo%2Bi%2Bmodify%2Bname%2Bservers%2Bfor%2Bmy%2Bdomain%2Bname%253F http://www.dnsmadeeasy.com/support/faq/ https://www.domain.com/help/article/domain-management-how-to-update-nameservers https://www.dotster.com/help/article/domain-management-how-to-update-nameservers https://help.dreamhost.com/hc/en-us/articles/360038897151 https://kb.easydns.com/knowledge/settingchanging-nameservers/ https://help.enom.com/hc/en-us/articles/115000486451-Nameservers-NS https://www.fastdomain.com/hosting/help/transfer_client_start https://billing.flokinet.is/index.php?rp=/knowledgebase/57/Nameserver-and-DNS-records.html https://docs.gandi.net/en/domain_names/common_operations/changing_nameservers.html https://www.godaddy.com/help/change-nameservers-for-your-domain-names-664 https://www.hostgator.com/help/article/changing-name-servers https://hostico.ro/docs/setarea-nameserverelor-din-contul-de-client-hostico/ https://my.hostmonster.com/cgi/help/222 https://support.hover.com/support/solutions/articles/201000064742-changing-your-domain-nameservers https://faq.internetbs.net/hc/en-gb/articles/4516921367837-How-to-update-Nameservers-for-a-domain https://www.ipage.com/help/article/domain-management-how-to-update-nameservers https://support.melbourneit.au/docs/modifying-domain-nameservers-or-dns-records https://support.moniker.com/hc/en-gb/articles/10101271418653-How-to-update-Nameservers-for-a-domain https://www.name.com/support/articles/205934457-registering-custom-nameservers https://www.namecheap.com/support/knowledgebase/article.aspx/767/10/how-can-i-change-the-nameservers-for-my-domain https://www.networksolutions.com/manage-it/edit-nameservers.jsp https://docs.ovh.com/gb/en/domains/web_hosting_general_information_about_dns_servers/#step-2-edit-your-domains-dns-servers https://kb.porkbun.com/article/22-how-to-change-your-nameservers https://support.rackspace.com/how-to/rackspace-name-servers/ https://www.register.com/knowledge https://support.squarespace.com/hc/articles/4404183898125-Nameservers-and-DNSSEC-for-Squarespace-managed-domains#toc-open-the-domain-s-advanced-settings https://kb.site5.com/dns-2/custom-nameservers/ https://cloud.ibm.com/docs/dns?topic=dns-add-edit-or-delete-custom-name-servers-for-a-domain https://helpcenter.yola.com/hc/articles/360012492660-Changing-your-name-servers

Once done it might take up to 24 hours for the changes to take effect.

Working with data

The definition of the model consists of Api name, model name and field (including nested fields) name(s) separated by dot “.”: {ApiName}.{ObjName}.{Field}, e.g: ErplyApi.Customer.FullName or ErplyApi.Customer.Address.City, where ErplyApi is name of the API, Customer - name of the model and rest is the field or if this is struct, then with nested fields.

  • If definition used as template data (wrapped into {{}}), then it should have prefix .Data, e.g. {{ .Data.ErplyApi.Customer.FullName }}
  • If definition used in the form input names, then omit .Data prefix, e.g. <input name="ErplyApi.Customer.FullName">

Each model name should have specific suffix which describes the scope where it is used:

  • Query (e.g. CustomerQuery) represents query model, usually used in search forms and submitted as a GET request
  • Input(optional) (e.g. CustomerInput) represents the input model, mostly used in forms during create or update actions. It is optional, an entity considered as an input object if there is no suffix specified. Input models can be used in form input names and optionally in values as data sets. One API entity may have multiple Input models.
  • Dto (e.g. CustomerDto) represents a data transfer object, usually it consists of one or many entities from different APIs. May be used in previews or tables.
  • List (e.g. CurrencyList) represents the list of simple entities, usually used for populating dropdowns.
  • DtoList (e.g. CustomerDtoList) is a list of Dto’s

Subsections of Working with data

Built-in models

Goerp has some built-in service variables, which can be used to pass some processing settings or to get some service variables, such as session key, client code, etc.

Subsections of Built-in models

Dynamic api

Introduction

Dynamic api gives an alternative way to handle the data. As opposed to the older model based handling. For benefits this functionality relies on the api’s business logic entirely and does not need adjustments to models once the api’s change.

It also allows to create as many request as you want against the same api and implements various data manipulation helper functions.

Subsections of Dynamic api

Defining requests

Defining requests

Requests are defined by either input parameters from the browser (query or post) or by static preset values. Preset values are values that are saved to the template during its save time and this means that changing values cannot be used with them.

The syntax of the requests is the following

Dynamic request structure Dynamic request structure

  • Api name Name of the api in question, reference the dynamic data source docs in the editor for the full list of options.
  • Api Always has the value of Api, this tells the system that we intend to use the dynamic api functionalities.
  • HTTP Method The http method that is to be used for the api call.
    • Get
    • Post
    • Put
    • Patch
    • Delete
    • FTPGet
    • FTPPut
  • Custom request name Custom name for the request you define, we use it later to set parameters and read the results.

Requests naming

Request names are case-sensitive. Prefer giving self-explanatory names to the requests, so they will not confuse while developing huge template with many requests. For example, if request made for v1/products and used method Get, then name it getProducts, but not something like request1, records, etc.

Also, GoErp have reserved names that cannot be used while defining the name for request.

Reserved names:

  • Session (with Capital S, session is ok to use)
  • Parameters (with Capital P)
  • Storage (with Capital S)

Using in a form

The definition of the name is given to the form input name and the endpoint to call from that api is given as the value.

Note that the value should not start with the character /.

<form method="get">
    <input name="ReportsApi.Api.Get.getProductsCall" value="v1/POSDay">
</form>

The call is made when the page is accessed only when the parameter is actually passed. In the sample above the parameter is registered in the form but is never actually sent as a parameter to the server. In order to load the data we would need to submit the form.

The method of the form would define how the parameter is passed to the server either via query parameter or a post parameter.

We can also load parameter into links to make the api to be called right away (before the page is enriched with data):

?ReportsApi.Api.Get.someReportsCall=v1/POSDay

This link would load the data when it is opened.

Defining static presets

When opening a page via url that has no query parameters then even if we have defined some api call into form they would not be triggered. Regular form values are not read from the template content, rather it is just written there to make the actual calls by the browser.

There is a way however to set defaults to parameters, these values are saved during template save time and are static values. This means that dynamic values that change in the template view process cannot be passed onto them, the values that are added to it are the exact values that will be used for the request.

This allows the request to be made with some default values and to make it load something when a page is opened without any parameters.

For this we can use the data-preset-val attribute on the inputs

<form method="get">
    <input name="ReportsApi.Api.Get.myReportsCall" 
           value="v1/POSDay" 
           data-preset-val="v1/POSDay">
</form>

Note that these do not need to be in the form as we might never send the parameter if we never intend to send the form. In this case a single hidden input that is not part of a form is enough.

<input type="hidden" name="ReportsApi.Api.Get.myStaticReportsCall"
       data-preset-val="v1/POSDay">

If the browser passes a parameter to the same value it will overwrite the preset value.

Url configuration static presets

From version 1.179.1 we can also set static preset values in the editor under the “URL configuration” menu. These name:value pairs work the same way as regular preset value definitions, but when defined here the values do not need to be in the template body at all.

Useful for pages that generate content that might not want to view the input but automatic loading of content is required.

URL configuration URL configuration

Request parameters

Defining request parameters

To pass parameters to the api we will use similar syntax like the one we use to define the requests.

Dynamic parameter structure Dynamic parameter structure

The custom request name here should match exactly the one we used to define the request.

In the following example we add the query parameter currencyId for the request we defined as myReportsCall

<form method="get">
    
    <input type="hidden" name="ReportsApi.Api.Get.myReportsCall" 
           value="v1/POSDay" 
           data-preset-val="v1/POSDay">

    <label for="currencyId">Currency ID</label>
    <input id="currencyId" type="number" 
           name="ReportsApi.Api.Query.myReportsCall.currencyId">

    <button type="submit">Fetch</button>
    
</form>

Default static values

Similar to the way we can give default values to requests we can also give default values to parameters.

Note that the values here are static meaning that we cannot add placeholders to the data-preset-val attribute. The value is being saved on template save time and will not change during the page view process.

<form method="get">
    
    <input type="hidden" name="ReportsApi.Api.Get.myReportsCall" 
           value="v1/POSDay" 
           data-preset-val="v1/POSDay">

    <!-- We make the page load with currency ID 1, however we still allow it to be overwritten by the parameter -->
    <label for="currencyId">Currency ID</label>
    <input id="currencyId" type="number" 
           name="ReportsApi.Api.Query.myReportsCall.currencyId" 
           data-preset-val="1">
    
    <button type="submit">Fetch</button>
    
</form>

If the browser passes a parameter to the same value it will overwrite the preset value.

Parameter types

Parameters are passed either via query or as post parameters. Samples here are generated into form inputs, but they can as well be used in url query parameters (name=value).

Query

We define the query type with the type name Query

<input type="number" 
       name="ReportsApi.Api.Query.myReportsCall.currencyId">

If we do not want to lose the value from the form field we can set the value to read from the request.

<input type="number" 
       name="ReportsApi.Api.Query.myReportsCall.currencyId" 
       value="{{ .Data.ReportsApi.Api.Requests.myReportsCall.Queries.currencyId }}">

This means the input will keep its value after the possible form submit and page reload

QueryBulk

Allows to pass as many query parameters as needed using one input. Parameter name here doesn’t matter actually as it would be ignored anyway, but we can always name it url just to make it clear to read

<input type="hidden" 
       name="CaFaApi.Api.QueryBulk.getConf.url"
       value="application=my-app&level=Company&name=dynamic-sample">

And to access those parameters just use the same Queries field like with regular Query parameters

<h1>Query parameters from bulk</h1>
<p>App: {{ .Data.CaFaApi.Api.Requests.getConf.Queries.application }}</p>
<p>Level: {{ .Data.CaFaApi.Api.Requests.getConf.Queries.level }}</p>
<p>Name: {{ .Data.CaFaApi.Api.Requests.getConf.Queries.name }}</p>

PostParam

Mostly for api’s that use urlencoded parameters or multipart/form-data (example: ErplyApi). Supports file uploading as well.

PostParam also used with FTP related calls to pass additional parameters like username, password and path.

We define the query type with the type name PostParam

<input type="number" 
       name="ErplyApi.Api.PostParam.getProducts.productID">

If we do not want to lose the value from the form field we can set the value to read from the request.

<input type="number" 
       name="ErplyApi.Api.PostParam.getProducts.productID"
       value="{{ .Data.ErplyApi.Api.Requests.getProducts.PostParams.productID }}">

And to uploading the file:

<input type="hidden" name="CDNApi.Api.Post.r1" value="images">
<input type="file" name="CDNApi.Api.PostParam.uploadImage.image">

We define the query type with the type name Header

<input id="currencyId" type="number" 
       name="ErplyApi.Api.Header.getProducts.myHeader">

If we do not want to lose the value from the form field we can set the value to read from the request.

<input type="number"
       name="ErplyApi.Api.Header.getProducts.myHeader"
       value="{{ .Data.ErplyApi.Api.Requests.getProducts.Headers.myHeader }}">

Path

If the api expects one of the parameters to be a path parameter we would define the request with a placeholder and follow it up with a value for this placeholder.

<input type="hidden" name="PricingApi.Api.Get.myCall"
       value="v1/price-lists/summary/{id}">

<input type="number" name="PricingApi.Api.Path.myCall.id" value="1">

Note here that the placeholder in the request definition needs to match that of the parameter name.

If we do not want to lose the value from the form field we can set the value to read from the request.

<input type="number"
       name="PricingApi.Api.Path.myCall.id"
       value="{{ .Data.PricingApi.Api.Requests.myCall.PathParams.id }}">

Json

Json inputs can be composed in a couple of different ways.

Input construction

With this method we will construct json with multiple input fields. We also need to define the type of the field for the json constructor. Type is given just before the parameter name. In many cases parameter name may represent the xpath in json, like string.inner.name will result in {"inner":{"name":""}}

Omitting key definition after the dot will result in setting input value as a root object to the json body.

Supported values

  1. string - result: ({"name":"foo"})
  2. number - result: ({"name":123})
  3. boolean - result: ({"name":true})
  4. json - result: ({"name":{"foo":"bar"}}). Allows to set any json structure.
  5. delete - (special case to delete entire path from the json, value of the input would be ignored because only path is needed)
<input type="number"
       name="PricingApi.Api.Json.createPriceList.string.name"
       value="myTestVal">

This sample would generate a json object like this

{
  "name": "myTestVal"
}

Adding multiple fields

<input type="text"
       name="PricingApi.Api.Json.createPriceList.string.name"
       value="myTestVal">
<input type="number"
       name="PricingApi.Api.Json.createPriceList.number.start"
       value="1000">

This sample would generate a json object like this

{
  "name": "myTestVal",
  "start": 1000
}

We can also define nested field

<input type="text"
       name="PricingApi.Api.Json.createPriceList.string.name"
       value="myTestVal">
<input type="number"
       name="PricingApi.Api.Json.createPriceList.number.test.start"
       value="1000">

This sample would generate a json object like this

{
  "name": "myTestVal",
  "test": {
    "start": 1000
  }
}

When one of the fields is of type array

<input type="text"
       name="PricingApi.Api.Json.myCall.string.name"
       value="myTestVal">
<input type="number"
       name="PricingApi.Api.Json.myCall.number.start.0"
       value="1000">

This sample would generate a json object like this

{
  "name": "myTestVal",
  "start": [
    1000
  ]
}

The number indicates the index of the array, knowing this we can construct the input with specific indexes

<input type="text"
       name="PricingApi.Api.Json.myCall.string.name"
       value="myTestVal">
<input type="number"
       name="PricingApi.Api.Json.myCall.number.start.0"
       value="1000">
<input type="number"
       name="PricingApi.Api.Json.myCall.number.start.1"
       value="1001">

This sample would generate a json object like this

{
  "name": "myTestVal",
  "start": [
    1000, 1001
  ]
}

Creating an array of objects to input.

<input type="text"
       name="PricingApi.Api.Json.myRequest1.string.code"
       value="myTestVal">
<input type="number"
       name="PricingApi.Api.Json.myRequest1.string.pairs.0.name"
       value="1000">
<input type="number"
       name="PricingApi.Api.Json.myRequest1.number.pairs.0.value"
       value="1001">

This sample would generate a json object like this

{
  "code": "myTestVal",
  "pairs": [
    {
      "name": "1000",
      "value": 1001
    }
  ]
}

Set entire json as a root object:

<input type="hidden" name="CaFaApi.Api.Json.putConf.json." 
       value='{"code":"test","name":"Foo","numbers":[1,2,4]}'>

Will generate payload like this:

{
  "code": "test",
  "name": "Foo",
  "numbers": [1, 2, 4]
}

Json input manipulation options

Starting from version 1.201.1, GoErp allows to make json modifications more flexible, like predefined json, connecting json parts from another request and many more. Check how it works with CAFA sample.

Raw

To send some custom data to the api endpoint that does not use a specific format (json, post params etc). Note that the key name here is not important (myCustomData) and it will not be used in the request.

<input type="hidden" name="AutomatApi.Api.Post.myRequest1"
       value="v5/some-endpoint">

<input type="hidden" name="AutomatApi.Api.Raw.myRequest1.myCustomData"
       value="custom-data">

Franchise

Usable for franchise accounts. Where the HQ account can make calls against certain stores. Note that the user on the HQ account needs to have access to the specified store through the multi account users feature.

<input type="hidden" name="AutomatApi.Api.Get.myRequest1"
       value="v2/local-application" data-preset-val="v2/local-application">

<input type="hidden" name="AutomatApi.Api.Franchise.myRequest1.clientCode"
       value="104373" data-preset-val="104373">

Using the value here will run the api call against that specific franchise account store.

Cache

Can be used to speed up page loads by caching certain api calls that do not use any parameters and are used mostly to fill selections. Items will be stored against the session and will refresh automatically within 1 hour (this can be altered with the duration parameter).

Define the cache key with a value of 1 to use the cache features. Adjusting the value to 0 or removing the key will disable the feature.

<input type="hidden" name="AccountAdminApi.Api.Get.timezones"
	 data-preset-val="v1/timezone">
<input type="hidden" name="AccountAdminApi.Api.Cache.timezones.session" data-preset-val="1">

Refresh cached results

Send the following parameter once to refresh the cached content for that request

<form method="post">
    <input type="hidden" name="AccountAdminApi.Api.Cache.timezones.refresh" value="1">
    <button type="submit">Refresh timezone cache</button>
</form>

Set custom duration

Optionally you can set a different duration for the cached elements (in seconds). This works together with the cache parameter and needs to exist when the data element is created.

If this value is not provided then the data will be cached by default for 3600 seconds.

<input type="hidden" name="AccountAdminApi.Api.Cache.timezones.duration" data-preset-val="7200">

Reading responses

Reading responses

The dynamic functionality returns the entire api response json content back as special json object that we can manipulate with special commands to get back data.

Specifying the request to get the data for

If we had for example previously defined a request like this

<input type="hidden" name="PricingApi.Api.Get.myCall"
       value="v1/price-lists"
       data-preset-val="v1/price-lists">

Then we can print out the json content with the following syntax.

{{ .Data.PricingApi.Api.Requests.myCall.Response }}

The response object holds all the requests in it and can be used to reference the results back for any of them.

Response data

The response objects will contain

Json result

{{ .Data.PricingApi.Api.Requests.myRequest1.Response }}

Response http code (from 1.184+)

{{ .Data.PricingApi.Api.Requests.myRequest1.ResponseHttpCode }}

Check if the response had a specific http code

{{ if .Data.PricingApi.Api.Requests.myRequest1.ResponseHttpCode 204 }}
    204 was indeed returned!
{{ end }}

Response headers (from 1.184+)

{{ .Data.PricingApi.Api.Requests.myRequest1.ResponseHeaders }}

Read specific response header content

{{ .Data.PricingApi.Api.Requests.myRequest1.ResponseHeaders.X-Some-Header }}

Get specific items

To get a specific field back from the response json content we can use the Get name.

Lets assume our api response data looks like this

[
  {
    "id": 1,
    "name": "Test",
    "start": "0000-00-00",
    "end": "0000-00-00",
    "isActive": 1
  },
  {
    "id": 3,
    "name": "Testing Update",
    "start": "0000-00-00",
    "end": "0000-00-00",
    "isActive": 1
  }
]

Our data is an array of elements, we can use a couple of different methods to read the keys.

Iterate the array

To iterate the through all items we would append the Response with Array, this tells the system that we are expecting an array of elements.

Each item in the array would correspond to the actual object, where we can use the Get function to fetch a specific field.

{{
<ul>
    {{ range .Data.PricingApi.Api.Requests.myRequest1.Response.Array }}
        <li>{{ .Get "id" }}</li>
    {{ end }}
</ul>
}}

Get a specific field value

The Get function can be used to return any single value from the json data.

Get a value from a specific index in the array. This would return the first results id value. The number indicates the array index.

{{ .Data.PricingApi.Api.Requests.myCall.Response.Get "0.id" }}

This function can return multiple items based on the provided syntax

{
  "name": {"first": "Tom", "last": "Anderson"},
  "age":37,
  "children": ["Sara","Alex","Jack"],
  "fav.movie": "Deer Hunter",
  "friends": [
    {"first": "Dale", "last": "Murphy", "age": 44, "nets": ["ig", "fb", "tw"]},
    {"first": "Roger", "last": "Craig", "age": 68, "nets": ["fb", "tw"]},
    {"first": "Jane", "last": "Murphy", "age": 47, "nets": ["ig", "tw"]}
  ]
}
{{ .Data.PricingApi.Api.Requests.myCall.Response.Get "name.last" }}           => Anderson
{{ .Data.PricingApi.Api.Requests.myCall.Response.Get "age" }}                 => 37
{{ .Data.PricingApi.Api.Requests.myCall.Response.Get "children" }}            => ["Sara","Alex","Jack"]
{{ .Data.PricingApi.Api.Requests.myCall.Response.Get "children.#" }}          => 3
{{ .Data.PricingApi.Api.Requests.myCall.Response.Get "children.1" }}          => Alex
{{ .Data.PricingApi.Api.Requests.myCall.Response.Get "child*.2" }}            => jack
{{ .Data.PricingApi.Api.Requests.myCall.Response.Get "c?ildren.0" }}          => Sara
{{ .Data.PricingApi.Api.Requests.myCall.Response.Get "fav\.movie" }}          => Deer Hunter
{{ .Data.PricingApi.Api.Requests.myCall.Response.Get "friends.#.first" }}     => ["Dale","Roger","Jane"]
{{ .Data.PricingApi.Api.Requests.myCall.Response.Get "friends.1.last" }}      => Craig

Type differences

The json values by default have special type that is always converted to a string when used in the template.

This means that if the intention is to use these values in some inbuilt helper function that expects a certain type and the expected types are not what is expected then the function can break the template entirely.

To prevent this we can cast the values to the correct type in the functions.

{{ if eq (.Data.PricingApi.Api.Requests.myCall.Response.Get "age").Int }} 37  }}
    <!-- Do something --> 
{{ end }}

The following type based functions can be called

{{ (.Data.PricingApi.Api.Requests.myCall.Response.Get "val").Exists }} => bool
{{ (.Data.PricingApi.Api.Requests.myCall.Response.Get "val").Int }}    => int64
{{ (.Data.PricingApi.Api.Requests.myCall.Response.Get "val").Float }}  => float64
{{ (.Data.PricingApi.Api.Requests.myCall.Response.Get "val").String }} => string
{{ (.Data.PricingApi.Api.Requests.myCall.Response.Get "val").Bool }}   => bool

Array query functionality

On array elements we can also use extra filtering options

If we used the sample below

[
  {
    "id": 1,
    "name": "Test",
    "start": "0000-00-00",
    "end": "0000-00-00",
    "isActive": 1
  },
  {
    "id": 3,
    "name": "Testing Update",
    "start": "0000-00-00",
    "end": "0000-00-00",
    "isActive": 1
  }
]

The following options are available

`#(name=="%s").id` "Test"`  => 1
`#(name=="%s")#.id` "Test"` => [1]
`#(id>2)#.id` "Test"`       => 1

Then in order to print only the name of the item that has id 3

<ul>
{{ $cs := .Data.CRMApi.Api.Requests.customer.Response.Array }}
    
    <li>{{ $cs.Get (printf `#(id==%d)#.name` 3) }}</li>
    
{{ end }}
</ul>

Data manipulation functions

These are special functions that can be used against the dynamic api json response content, to do special conversions or other special functions with them.

Note that the following functions can also be used on the same result, for example we want to flatten and get unique values at the same time.

{{ .Data.ErplyApi.Api.Requests.getProducts.Response.Get "records.#.productId|@flatten|@unique" }}
  • @commaSepStr - turn an array into comma separated string

If we had a json result like this

{
  "records": [
    {
      "id": 1,
      "name": "Test 1"
    },
    {
      "id": 2,
      "name": "Test 2"
    }
  ]
}

Get the arrays specific values as comma separated string

{{ $commaSeparatedString := .Data.ErplyApi.Api.Requests.getProducts.Response.Get "records.#.id|@commaSepStr" }}
  • @reverse - reorder an array in reverse order

If we had a json result like this

{
  "records": [
    {
      "id": 1,
      "name": "Test 1"
    },
    {
      "id": 2,
      "name": "Test 2"
    }
  ]
}

We can use the function here to get the records array in reverse order

{{ $reversedRecords := .Data.ErplyApi.Api.Requests.getProducts.Response.Get "records@reverse" }}

We can get the last element in the array using this

{{ $lastRecord := .Data.ErplyApi.Api.Requests.getProducts.Response.Get "records@reverse.0" }}
  • @keys - returns object field keys as an array

Using the same json sample above

Get the keys of the first record

{{ $keys := .Data.ErplyApi.Api.Requests.getProducts.Response.Get "records.0|@keys" }}
  • @values - returns object field values as an array

Using the same json sample above

Get the values of the first record

{{ $values := .Data.ErplyApi.Api.Requests.getProducts.Response.Get "records.0|@values" }}
  • @flatten - returns a flat array of the values
{
  "records": [
    {
      "id": 1,
      "rows": [
        {"id":  3},
        {"id":  4}
      ]
    },
    {
      "id": 2,
      "rows": [
        {"id":  5},
        {"id":  6}
      ]
    }
  ]
}

Get a flat array of values

{{ $values := .Data.ErplyApi.Api.Requests.getProducts.Response.Get "records.#.rows.#.id|@flatten" }}

[3,4,5,6]
  • @unique - returns an array of unique values only
{
  "records": [
    {
      "productId": 1
    },
    {
      "productId": 2
    },
    {
      "productId": 1
    }
  ]
}

Get the unique values

{{ $values := .Data.ErplyApi.Api.Requests.getProducts.Response.Get "records.#.productId|@unique" }}

[1,2]

Chaining requests

Chain multiple api requests

It’s possible to chain different api fetches together. This is useful when a second api call would depend on the results of the first.

Warning

By default, all subsequent requests will be skipped if parent one fails. Although, it is possible to configure this behavior by making link optional. More details

Available chaining options

NB! Chaining options supported through value AND through data-preset-val parameters

Every request input parameters may be chained with other request and with data from GoErp in-build data sets.

In-build data sets:

  • Session (check structure of the Session by printing it out {{ toJson .Session }})
    <input type="hidden" name="ErplyApi.Api.PostParam.getSD.<-clientID" value="Session.customer.ID" data-preset-val="Session.customer.ID">
  • Parameters (custom parameters)
    <input type="hidden" name="ErplyApi.Api.PostParam.getSD.<-clientID" value="Parameters.customerId" data-preset-val="Parameters.customerId">
  • Storage (session storage)
    <input type="hidden" name="ErplyApi.Api.PostParam.getSD.<-clientID" value="Storage.customerId" data-preset-val="Storage.customerId">
  • Application variables (application variables)
    <input type="hidden" name="ErplyApi.Api.PostParam.getSD.<-clientID" value="Variables.customerId" data-preset-val="Variables.customerId">

Data sets from other requests:

There is also option to chain request input parameters with parameters taken from other requests:

  • PathParams (request input parameter, available before and after call gets executed)
    <input type="hidden" name="ErplyApi.Api.PostParam.getSD.<-clientID" value="otherRequest.PathParams.customerId" data-preset-val="otherRequest.PathParams.customerId">
  • Queries (request input parameter, available before and after call gets executed)
    <input type="hidden" name="ErplyApi.Api.PostParam.getSD.<-clientID" value="otherRequest.Queries.customerId" data-preset-val="otherRequest.Queries.customerId">
  • Headers (request input parameter, available before and after call gets executed)
    <input type="hidden" name="ErplyApi.Api.PostParam.getSD.<-clientID" value="otherRequest.Headers.customerId" data-preset-val="otherRequest.Headers.customerId">
  • PostParams (request input parameter, available before and after call gets executed)
    <input type="hidden" name="ErplyApi.Api.PostParam.getSD.<-clientID" value="otherRequest.PostParams.customerId" data-preset-val="otherRequest.PostParams.customerId">
  • Response (response parameter of the request, available only after call gets executed)
    <input type="hidden" name="ErplyApi.Api.PostParam.getSD.<-clientID" value="otherRequest.Response.customerId" data-preset-val="otherRequest.Response.customerId">
  • ResponseHeaders (response parameter of the request, available only after call gets executed)
    <input type="hidden" name="ErplyApi.Api.PostParam.getSD.<-clientID" value="otherRequest.ResponseHeaders.customerId" data-preset-val="otherRequest.ResponseHeaders.customerId">

Set order of calls by grouping them

We can use the grouping definition to set the order of calls.

The pipe | at the end indicates grouping and the number says the order. Multiple requests can have the same group number, this means they would be done at the same time, and they do not depend on each other.

<input type="hidden" name="PIMApi.Api.Get.getProducts|1" data-preset-val="v1/product">
<input type="hidden" name="CDNApi.Api.Get.getImages|2" data-preset-val="v1/images">

The following case is also ok, as the non numbered calls will be called first

<input type="hidden" name="PIMApi.Api.Get.getProducts" data-preset-val="v1/product">
<input type="hidden" name="PricingApi.Api.Get.getPrices|1" data-preset-val="v1/products/price-tax-rate">

Set values from another call as input parameters

It is recommended to make as few requests as possible. This means that creating requests in first api response loops is not recommended as it would take a large performance hit.

Instead, we can use special helper functions to collect the id’s and make a separate calls. |@commaSepStr converts all the id’s of the get products response to comma separated string input for the pricing api. Then we assign this value to productId parameter using the <- sign.

<input type="hidden" name="PricingApi.Api.Query.getPrices.<-productIDs" value="getProducts.Response.#.id|@commaSepStr">

The value syntax here is similar to how the Get function works, so we can also assign single values when needed.

<!-- Add the id value of the first result -->
<input type="hidden" name="PricingApi.Api.Query.getPrices.<-productIDs" value="getProducts.Response.0.id">

Dynamics provides option to make link optional, which means, if parent request fails or there is no data in chained location then request would be still executed but with empty parameter (from version 1.262.1: query, path and headers parameters will be skipped instead of empty values).To make link optional just put ? after the link operator <-, like this <-?:

<input type="hidden" name="PricingApi.Api.Query.getPrices.<-?productIDs" value="getProducts.Response.0.id">

Skip chain if parent request has data

Dynamics provides option to skip the chain when parent has a value. That option could be useful when we need to create a new record only if the parent request has no data. To skip the chain, put ! after the link operator <-, like this <-!:

<!-- getPrices would be not executed if getProducts.Response.0.id has id in there -->
<input type="hidden" name="PricingApi.Api.Query.getPrices.<-!productIDs" value="getProducts.Response.0.id">

Define multiple sources for chaining

Sometimes we need to get first non-empty value from multiple sources. This can be done by using the || operator between sources. This will take the first non-empty value from the provided set of sources. If all sources are empty and chain not optional, then request will be not executed. Works with presets as well.

<input type="hidden" 
       name="CRMApi.Api.Query.customers.<-ids"
       value="Parameters.cid||Storage.cid||Variables.defaultValue">

Merge data from different api calls

We can use the json query mechanism to get items from other api responses, or we can use the old-fashioned id matching.

Query features

Using the inbuilt query features to return exact values. This can be useful when dealing with more complex pages that try to compose multiple requests.

<ul>
{{ range .Data.PIMApi.Api.Requests.getProducts.Response.Array }}
    <!-- As values returned by the _Get_ function do not have a specific type we need to cast it into one -->
    {{ $productId := (.Get "id").Int }}
	<li>
        Product code: {{ .Get "code" }}<br>
        <!-- Get the price_with_tax value from the prices array by matching the productID there with current iteration id --> 
        Price: {{ .Data.PricingApi.Api.Requests.getPrices.Response.Get (printf `#(productID==%d).price_with_tax` $productId) }}
    </li>
{{ end }}
</ul> 

Id matching

Iterating through the second set is also an option. For something smaller this will probably be enough.

<ul>
{{ range $product := .Data.PIMApi.Api.Requests.getProducts.Response.Array }}
	<li>
        Product code: {{ $product.Get "code" }}<br>
        
        {{ range $price := $.Data.PricingApi.Api.Requests.getPrices.Response.Array }}
            <!-- Still need to cast to the appropriate types -->
            {{ if eq ($price.Get "productID").Int ($product.Get "id").Int }}
                <!-- Get the price_with_tax value if the id matches -->
                Price: {{ $price.Get "price_with_tax" }}
            {{ end }}
        {{ end }}

    </li>
{{ end }}
</ul> 

Sample

In here we fetch a list of products and generate a list of images for them.

<div class="my-error-container">
    {{ range .Data.Errors }}
    <span class="my-error-message">{{ . }}</span>
    {{ end }}
</div>

<input type="hidden" name="PIMApi.Api.Get.getProducts" data-preset-val="v1/product">
<input type="hidden" name="PricingApi.Api.Get.getPrices|1" data-preset-val="v1/products/price-tax-rate">

<input type="hidden" name="PricingApi.Api.Query.getPrices.warehouseID" data-preset-val="1">
<input type="hidden" name="PricingApi.Api.Query.getPrices.<-productIDs" 
    data-preset-val="getProducts.Response.#.id|@commaSepStr">

<ul>
    {{ range .Data.PIMApi.Api.Requests.getProducts.Response.Array }}
    <!-- As values returned by the _Get_ function do not have a specific type we need to cast it into one -->
    {{ $productId := (.Get "id").Int }}
    <li>
        Product code: {{ .Get "code" }}<br>
        <!-- Get the price_with_tax value from the prices array by matching the productID there with current iteration id -->
        Price: {{ $.Data.PricingApi.Api.Requests.getPrices.Response.Get (printf `#(productID==%d).price_with_tax` $productId) }}
    </li>
    {{ end }}
</ul>

A more complex sample

This is a more complex sample that uses multiple api calls to save a new customer and various other elements related to the customer.

Errors: {{ .Data.Errors }}<br/>

<h1>Customer input with address and fetching customer with all data in place</h1>

<form method="post">
    <!-- requests initialization -->
    <input type="hidden" name="CRMApi.Api.Get.customerGroups" value="v1/customers/groups"
           data-preset-val="v1/customers/groups">
    <input type="hidden" name="CRMApi.Api.Post.createCustomer" value="v1/customers/individuals">
    <input type="hidden" name="CRMApi.Api.Post.createAddress|1" value="v1/addresses">
    <input type="hidden" name="CRMApi.Api.Get.customer|1" value="v1/customers">
    <input type="hidden" name="CRMApi.Api.Get.getAddress|2" value="v1/addresses">
    <input type="hidden" name="CRMApi.Api.Put.createAttribute|2" value="v1/attributes">
    <input type="hidden" name="CRMApi.Api.Delete.delAttribute|3" value="v1/attributes/{id}">
    <input type="hidden" name="CRMApi.Api.Get.getAddressTypes|3" value="v1/addresses/types">
    <input type="hidden" name="CRMApi.Api.Get.getBusinessAreas|3" value="v1/business/areas">
    <input type="hidden" name="CRMApi.Api.Get.getAttributes|4" value="v1/attributes">
    <!-- presets data -->
    <input type="hidden" name="CRMApi.Api.Query.customerGroups.take" value="" data-preset-val="50">

    <!-- customer post, depth = 1 -->
    <br/>First name:
    <input type="text" name="CRMApi.Api.Json.createCustomer.string.firstName"
           value="{{ .Data.CRMApi.Api.Requests.customer.Json.Get "firstName" }}">

    <br/>Last name:
    <input type="text" name="CRMApi.Api.Json.createCustomer.string.lastName"
           value="{{ .Data.CRMApi.Api.Requests.customer.Json.Get "lastName" }}">

    {{ $gId := (.Data.CRMApi.Api.Requests.customer.Json.Get "customerGroupId").Int }}
    <br/>Group: <select name="CRMApi.Api.Json.createCustomer.number.customerGroupId">
        {{ range $cg := .Data.CRMApi.Api.Requests.customerGroups.Response.Array }}
            <option value="{{ $cg.Get "id" }}" {{ if eq ($cg.Get "id").Int $gId }}selected{{ end }}>
                {{ $cg.Get "name.en" }}
            </option>
        {{ end }}
    </select>

    <!-- address post, depth = 2 -->
    <br/>Address street:
    <input type="text" name="CRMApi.Api.Json.createAddress.string.street"
           value="{{ .Data.CRMApi.Api.Requests.createAddress.Json.Get "street" }}">
    <input type="hidden" name="CRMApi.Api.Json.createAddress.number.<-customerId"
           value="createCustomer.Response.id">
    <input type="hidden" name="CRMApi.Api.Json.createAddress.number.typeId" value="1">

    <!-- get created customer, depth = 2 -->
    <input type="hidden" name="CRMApi.Api.Query.customer.<-ids" value="createCustomer.Response.id">

    <!-- get created address, depth = 3 -->
    <input type="hidden" name="CRMApi.Api.Query.getAddress.<-customerIds"
           value="customer.Response.#.id|@commaSepStr">

    <br/>Attribute int:
    <input type="number" name="CRMApi.Api.Json.createAttribute.number.value" value="int">
    <input type="hidden" name="CRMApi.Api.Json.createAttribute.string.entity" value="customer">
    <input type="hidden" name="CRMApi.Api.Json.createAttribute.string.name" value="test-dynamic-4">
    <input type="hidden" name="CRMApi.Api.Json.createAttribute.string.type" value="int">
    <input type="hidden" name="CRMApi.Api.Json.createAttribute.number.<-record_id"
           value="customer.Response.#.id|@commaSepStr">

    <!-- get created address, depth = 4 -->
    <input type="hidden" name="CRMApi.Api.Path.delAttribute.<-id" value="createAttribute.Response.id">

    <input type="hidden" name="CRMApi.Api.Query.getAttributes.entityName" value="customer">
    <input type="hidden" name="CRMApi.Api.Query.getAttributes.<-recordIds"
           value="customer.Response.#.id|@commaSepStr">

    <br/>
    <button type="submit">Submit</button>
</form>

<h1>Created customer</h1>
{{ $cs := .Data.CRMApi.Api.Requests.customer.Response.Array }}
{{ if $cs }}
    <br/>Response count: {{ len $cs }}
    {{ $c := index $cs 0 }}
    {{ $cId := ($c.Get "id").Int }}
    <br/>ID: {{ $cId }}
    <br/>First name: {{ $c.Get "firstName"}}
    {{ $cgId := ($c.Get "customerGroupId").Int }}
    <br/>Customer group: {{ .Data.CRMApi.Api.Requests.customerGroups.Response.Get (printf `#(id==%d).name.en` $cgId) }}
    <br/>Addresses: {{ $.Data.CRMApi.Api.Requests.getAddress.Response.Get (printf `#(customerId==%d)#.street` $cId) }}
{{ end }}

<h1>Attributes</h1>
{{ $attrs := .Data.CRMApi.Api.Requests.getAttributes.Response.Array }}
{{ if $attrs }}
    {{ range $attrs }}
        <br/>{{ .Get "name" }} | {{ .Get "type" }} | {{ .Get "value" }}
    {{end}}
{{ end }}

A sample using chaining in reading images

A sample where we connect CDN images to product groups. In here we can see that we can connect items that naturally do not have a connection in the database.

Before this sample 2 images have been created using the CDN api create image endpoint.

context: erply-product-group productId: 0 for default and 3 for the existing one

<!-- Define request 1 for product groups -->
<input type="hidden" name="PIMApi.Api.Get.myProdGroups" value="v1/product/group" data-preset-val="v1/product/group">

<!-- Chain images from CDN api, use results from first as input -->
<input type="hidden" name="CDNApi.Api.Get.myImages|1" value="images" data-preset-val="images">
<input type="hidden" name="CDNApi.Api.Query.myImages.context" data-preset-val="erply-product-group">
<input type="hidden" name="CDNApi.Api.Query.myImages.<-productId" data-preset-val="myProdGroups.Response.#.id|@commaSepStr">

<p>Groups</p>
<ul>
    {{ range .Data.PIMApi.Api.Requests.myProdGroups.Response.Array }}
    {{ $groupId := (.Get "id").Int }}
    <li>
        {{ .Get "name.en" }} (ID: {{ $groupId }})

        <!-- Fetch images for this group id using the dynamic query method -->
        {{ $images := $.Data.CDNApi.Api.Requests.myImages.Response.Get (printf `images.#(productId==%d)#.key` $groupId) }}
        <ul>
            {{ if $images.Array }}
            <!-- print matching items -->
            {{ range $images.Array }}
            <li>
                <img src="{{ $.Session.Services.CDNApi.URL }}/images/{{ $.Session.ClientCode }}/{{ . }}?width=200&height=200">
            </li>
            {{ end }}
            {{ else }}
            <!-- Print default -->
            <li>
                <img src="{{ $.Session.Services.CDNApi.URL }}/images/{{ $.Session.ClientCode }}/jJumxcXTSamiGhJgDGJ1kGFyQx4iqtksv4R3MnEsIc4APVqt2v.png?width=200&height=200">
            </li>
            {{ end }}
        </ul>
    </li>
    {{ end }}
</ul>

Result should look like this

Cdn chain sample Cdn chain sample

Form redirect

Form redirect with dynamic api

Dynamic api uses a different kind of redirect from the models.

This redirect is only triggered if all the calls in the request succeed. If even one of them fails (errors model in the response contains something) then by default redirection is not triggered. However, there is a way to define optional requests that allowed to fail.

We define the redirection with Form.Redirect parameter The syntax here is exactly the same as with responses, only difference is that instead of the usual {{ and }} escapes we use [[ and ]] instead.

Use Form.AllowedToFailRequests input name to pass request names that allowed to fail and make redirect to the desired page even if they failed. Request names should be separated by comma (,)

<!-- Assign the first id from the response to the redirect link -->
<input type="hidden" name="Form.Redirect" value="da-dynamic-redirect?someParameter=[[ .Data.PIMApi.Api.Requests.request1.Response.Get `0.id` ]]">

Sample

<form method="get">
    <!-- For 'request1' use your own custom identifier, use it to access the results -->
    <input type="hidden" name="PIMApi.Api.Get.request1" value="/v1/product">

    <!-- Only redirects if all form containing requests succeed -->
    <input type="hidden" name="Form.Redirect" value="da-dynamic-redirect?wat=[[ .Data.PIMApi.Api.Requests.request1.Response.Get `0.id` ]]">
    <input type="hidden" name="Form.AllowedToFailRequests" value="request1">
    <button type="submit">Redirect on success</button>
</form>

Input helpers

Helper functions for input

These are functions that help in generating input in a certain format that is difficult to create using regular html inputs.

@pimFilter & readPimFilter

Note

The regular pimFilter does not support ‘or’ operator between conditions and the operator always set to ‘and’. It also does not support nested conditions. If the operator between conditions is needed or nesting of conditions then look for the pimFilterV2 helper instead

PIM api filter structure is in json format and simple input by default cannot use it in a clean way. The following functions help in generating the filters for it.

This is used at the end of the input name definition

<input type="text" name="PIMApi.Api.Query.getProductsReq.filter@pimFilter(id,in,number)">
[["id","in",[1]]]

Syntax for it is as follows:

  1. Name of the field in pim
  2. Operator: =, !=, >=, <=, in, not in, contains and startswith
  3. Type of the value: string, number, bool

Note that in and not in operators expect the value to be a comma separated list of values.

<input type="text" name="PIMApi.Api.Query.getProductsReq.filter@pimFilter(id,in,number)"
    value='1,2,3'>
[["id","in",[1,2,3]]]

Due to json filter structure and special characters in the instructions it’s not possible to read the value back into the input fields using regular methods. For this we can use another helper function

<input type="text" name="PIMApi.Api.Query.getProductsReq.filter@pimFilter(id,in,number)"
    value='{{ readPimFilter .Data.Parameters "getProductsReq" "id,in,number" }}'>
[["id","in",[1]]]

Required parameters:

  1. Parameters (usually .Data.Parameters)
  2. Name of the request
  3. Filter definition

We can add multiple filters to the same page, they will all be appended with the “and” operator automatically

<input type="text" name="PIMApi.Api.Query.getProductsReq.filter@pimFilter(id,in,number)"
   value='{{ readPimFilter .Data.Parameters "getProductsReq" "id,in,number" }}'>
<input type="text" name="PIMApi.Api.Query.getProductsReq.filter@pimFilter(code,contains,string)"
   value='{{ readPimFilter .Data.Parameters "getProductsReq" "code,contains,string" }}'>
[["id","in",[1]],"and",["code","contains","value"]]

Since . is reserved for template operators we need to define nested fields with -> instead

<input type="text" name="PIMApi.Api.Query.getProductsReq.filter@pimFilter(name->en,contains,string)"
    value='{{ readPimFilter .Data.Parameters "getProductsReq" "name->en,contains,string" }}'>
[["name.en","contains","value"]]

@pimFilterV2, pimFilterOperatorV2 & readPimFilterV2

Used to help generate pim filter json type structure from html inputs. Similar to the version 1 of the pimFilter only that this one supports different operators between conditions and optionally also nesting of conditions.

Also since the ordering of the elements is important then the order helper should also be used when using the V2 variant.

Syntax for it is as follows:

  1. Name of the field in pim
  2. Operator: =, !=, >=, <=, in, not in, contains and startswith
  3. Type of the value: string, number, bool
  4. Optional. Add a nested group identifier, this is a random string value that group similar values to a nested group.

Note that in and not in operators expect the value to be a comma separated list of values.

<input type="text" name="PIMApi.Api.Query.getProductsReq.filter@{order(1);pimFilterV2(id,in,number)}">
[["id","in",[1]]]

Due to json filter structure and special characters in the instructions it’s not possible to read the value back into the input fields using regular methods. For this we can use another helper function readPimFilterV2

<input type="text" name="PIMApi.Api.Query.getProductsReq.filter@{order(1);pimFilterV2(id,in,number)}"
    value='{{ readPimFilterV2 .Data.Parameters "getProductsReq" "id,in,number" }}'>
[["id","in",[1]]]

We can add multiple filters to the same page, but we also need add an operator between the conditions using the pimFilerOperatorV2 helper.

<input type="text" name="PIMApi.Api.Query.getProductsReq.filter@{order(1);pimFilterV2(id,in,number)}"
   value='{{ readPimFilter .Data.Parameters "getProductsReq" "id,in,number" }}'>
<input type="hidden" name="PIMApi.Api.Query.getProductsReq.filter@{order(2);pimFilterOperatorV2()}" value="and">
<input type="text" name="PIMApi.Api.Query.getProductsReq.filter@{order(3);pimFilter(code,contains,string)}"
   value='{{ readPimFilter .Data.Parameters "getProductsReq" "code,contains,string" }}'>
[["id","in",[1]],"and",["code","contains","value"]]

Optionally we can also create nested filters with the group identifier parameter. Do not forget to update the order of the functions and also the operator between possible groups.

In the sample we provide it a value of ‘a’, but the value is user defined. Everything with the value will be nested in the same array.

<input type="text" name="PIMApi.Api.Query.myRequest1.filter@{order(1);pimFilterV2(id,in,number,a)}"
       value='{{ readPimFilterV2 .Data.Parameters "myRequest1" "id,in,number,a" }}'>
<input type="hidden" name="PIMApi.Api.Query.myRequest1.filter@{order(2);pimFilterOperatorV2(a)}" value="or">
<input type="text" name="PIMApi.Api.Query.myRequest1.filter@{order(3);pimFilterV2(code,contains,string,a)}"
       value='{{ readPimFilterV2 .Data.Parameters "myRequest1" "code,contains,string,a" }}'>
<input type="hidden" name="PIMApi.Api.Query.myRequest1.filter@{order(4);pimFilterOperatorV2()}" value="or">
<input type="text" name="PIMApi.Api.Query.myRequest1.filter@{order(5);pimFilterV2(code2,contains,string)}"
       value='{{ readPimFilterV2 .Data.Parameters "myRequest1" "code2,contains,string" }}'>
<input type="hidden" name="PIMApi.Api.Query.myRequest1.filter@{order(6);pimFilterOperatorV2()}" value="or">
<input type="text" name="PIMApi.Api.Query.myRequest1.filter@{order(7);pimFilterV2(code3,contains,string)}"
       value='{{ readPimFilterV2 .Data.Parameters "myRequest1" "code3,contains,string" }}'>
[["id","in",[1]],"or",["code","contains","test"],"or",["code2","contains","test2"],"or",["code3","contains","test3"]]

Json

Functions that related to the Json parameter type.

JSON field names may contain @ symbol, so function section definition starts with @{ and ends with } to avoid collisions. E.g. @{order(1);foo(bar,rab)}. Functions separated by semicolon ;. Function parameters separated by comma ,.

@order

Defines the order of inputs related to the json generation process. In next sample initial structure of the json input would always be executed first and only then all other inputs. This allows us to predefine the payload of json body and then change only necessary fields.

<input type="hidden" 
       name="CaFaApi.Api.Json.putConf.json.@{order(1)}" 
       value='{"application":"my-app","level":"Company","name":"dynamic-sample","value":{}}'>

also may be used with chaining:

<input type="hidden" 
       name="CaFaApi.Api.Json.putConf.json.<-@{order(1)}" 
       value="getConfState.Response.0">

@setDefaultDate

Used to set default preset date values that have a value based on the current date. For example some api should get a default start time of today.

Parameters:

  1. Timezone (use ‘from-conf’ to get the timezone value from accounts configuration)
  2. Format for the date value (https://gosamples.dev/date-time-format-cheatsheet/) (use ‘unix’ to get the value as unix timestamp)
  3. (Optional) Adjust by date value (minute, hour, day, month or year)
  4. (Optional) Integer (negative or positive) for the adjustment value
  5. (Optional) Static value to suffix the formatted date on output
  6. (Optional) Turn the static value to a prefix instead

Note it’s important to give the preset value of “default” for the function to insert the date value. This is so that possible manual values will never be overwritten.

<input type="text" name="ReportsApi.Api.Query.myRequest1.date@{setDefaultDate(UTC,02-01-06 15:04:05 MST)}"
    value="" data-preset-val="default">

With the optional date adjustment parameters.

<input type="text" name="ReportsApi.Api.Query.myRequest1.dateFrom@{setDefaultDate(UTC,02-01-06 15:04:05 MST)}"
    value="" data-preset-val="default">
<input type="text" name="ReportsApi.Api.Query.myRequest1.dateTo@{setDefaultDate(UTC,02-01-06 15:04:05 MST,month,1)}"
       value="" data-preset-val="default">

Using the optional suffix and prefix.

<!-- As suffix, without the 6th optional parameter -->
<input type="hidden" name="PIMApi.Api.Query.myRequest1.date@{setDefaultDate(UTC,2006-01-02,2006-01,day,0, 00:00:00)}"
    value="" data-preset-val="default">
<!-- Would generate: 2024-08 00:00:00 -->

<!-- As prefix with the 6th parameter -->
<input type="hidden" name="PIMApi.Api.Query.myRequest1.date@{setDefaultDate(UTC,2006-01-02,2006-01,day,0,00:00:00 ,true)}"
       value="" data-preset-val="default">
<!-- Would generate: 00:00:00 2024-08-->

It’s also possible to set the function to read the current accounts timezone configuration value instead. To do this set the timezone value to from-conf.

<input type="text" name="ReportsApi.Api.Query.myRequest1.date@{setDefaultDate(from-conf,02-01-06 15:04:05 MST)}"
    value="" data-preset-val="default">

Doing this the function will look for the value from accounts configuration:timezone value, if the value is not set or has an invalid value then it defaults to UTC.

@formatDate

Used to alter the date format to formats that the possible api requires. Mostly useful when the visual representation of the value in the UI is not the same as the api requires.

For example of the api needs unix but the UI should display regular year, month and day.

Parameters:

  1. Timezone (use ‘from-conf’ to get the timezone value from accounts configuration)
  2. Format for the date value (UI) (https://gosamples.dev/date-time-format-cheatsheet/) (use ‘unix’ to get the value as unix timestamp)
  3. Format of the output (API) (https://gosamples.dev/date-time-format-cheatsheet/) (use ‘unix’ to set the value as unix timestamp)
  4. (Optional) Adjust by date value (minute, hour, day, month, year, year, dtFirstDayOfWeek, dtLastDayOfWeek, dtFirstDayOfMonth, dtLastDayOfMonth, dtFirstDayOfYear or dtLastDayOfYear)
  5. (Optional) Integer (negative or positive) for the adjustment value. for “dt” related types first applies adjustment then sets specific time. “dt” for weeks accepts only 0 or 1, which is bool for isSunday
  6. (Optional) Static value to suffix the formatted date on output
  7. (Optional) Turn the static value to a prefix instead
    <!-- In this sample we take the value of 2024-04-25 and set it as unix timestamp when the api request is made -->
    <input type="text" name="PIMApi.Api.Query.myRequest1.date@{formatDate(UTC,2006-01-02,unix)}"
           value="2024-04-25">

With the optional date adjustment parameters.

    <!-- In this sample we take the value of 2024-04-25, append 5 days and set it as unix timestamp when the api request is made -->
    <input type="text" name="PIMApi.Api.Query.myRequest1.date@{formatDate(UTC,2006-01-02,unix,day,5)}"
           value="2024-04-25">

Using the optional suffix and prefix.

<!-- As suffix, without the 7th optional parameter -->
<input type="hidden" name="PIMApi.Api.Query.myRequest1.date@{formatDate(UTC,2006-01-02,2006-01,day,0, 00:00:00)}" 
       data-preset-val="2024-04-25">
<!-- Would generate: 2024-08 00:00:00 -->

<!-- As prefix with the 7th parameter -->
<input type="hidden" name="PIMApi.Api.Query.myRequest1.date@{formatDate(UTC,2006-01-02,2006-01,day,0,00:00:00 ,true)}" 
       data-preset-val="2024-04-25">
<!-- Would generate: 00:00:00 2024-08-->

@encode

Version 1.222+

Can be used to encode the value. Supports base32, base64 and hex. Default is base64 if parameter is omitted, provide the parameter to use a different encoding.

<!-- Defaults to base64 -->
<input type="text" name="ReportsApi.Api.Query.myRequest1.someData@{encode}"
    value="some value to be encoded">
<!-- Request specific -->
<input type="text" name="ReportsApi.Api.Query.myRequest1.someData@{encode(base64)}"
       value="some value to be encoded">
<!-- Hex example -->
<input type="text" name="ReportsApi.Api.Query.myRequest1.someData@{encode(hex)}"
       value="some value to be encoded">

@decode

Version 1.222+

Can be used to decode an encoded api response value to a string (supports base32, base64 and hex). Default is base64 if parameter is omitted, provide the parameter to use a different encoding.

<!-- Defaults to base64 -->
<input type="text" name="ReportsApi.Api.Query.myRequest1.someData@{decode}"
    value="some value to be encoded">
<!-- Request specific -->
<input type="text" name="ReportsApi.Api.Query.myRequest1.someData@{decode(base64)}"
       value="some value to be encoded">
<!-- Hex example -->
<input type="text" name="ReportsApi.Api.Query.myRequest1.someData@{decode(hex)}"
       value="dGVzdA==">

@hash

Version 1.222+

Can be used to hash an api value according to an algorithm. Supports md5, sha1, sha256 and sha512. Defaults to sha1 if the parameter is not provided.

<!-- Defaults to sha1 -->
<input type="text" name="ReportsApi.Api.Query.myRequest1.someData@{hash}"
    value="some value to be encoded">
<!-- Request specific -->
<input type="text" name="ReportsApi.Api.Query.myRequest1.someData@{hash(sha1)}"
       value="some value to be encoded">
<!-- md5 example -->
<input type="text" name="ReportsApi.Api.Query.myRequest1.someData@{hash(md5)}"
       value="value to be hashed">

@encrypt / @decrypt

Version 1.254+

This can be used to encrypt the data that is being saved in the api (kvs for example). This means that the data will only be readable when the correct key is provided.

The feature uses AES cipher, and the protection strength is determined by the given key length. Note that the keys need to use the correct length (16, 24 or 32)

  • 16 characters : AES-128
  • 24 characters : AES-192
  • 32 characters : AES-256

Setting the keys to requests

Set the key for the request so the encryption knows what it needs to use.

    <!--
    
        Remember that ordering is important here as we need the system to always have the key before any encryption. 
        In this sample we use the chaining to take the key from the variables file, but the value can be taken from
        any other api call or from a hardcoded value.
    -->
    <input type="hidden" name="KvsApi.Api.Encrypt.myRequest1.<-key@{order(1)}"
            value="Variables.someKey" data-preset-val="Variables.someKey">

Also note that if the save and read is done on the same page then the key should be added for both calls.

Encrypting the values

Once we have set the key then we can use the encrypt helper on the values we want to protect.

    <!--
        Use the order here to make sure that this is done after the key set.
        Any api string value can be protected like this (limited to the max length of them).
     -->
    <input type="text" name="KvsApi.Api.Json.myRequest1.string.value@{order(2);encrypt}"
        value="some value to be encoded">

Decrypting

Remember to set the key for reading, otherwise the api will return an unreadable string.

    <!-- When reading we also need to set the key -->
    <input type="hidden" name="KvsApi.Api.Encrypt.myRequest2.<-key"
    	value="Variables.someKey" data-preset-val="Variables.someKey">

Use the decrypt tools helper to read the data.

Note

The method will not inform you of an invalid or missing keys, instead it will generate random garbage values every single load.

<ul>
{{ range .Data.KvsApi.Api.Requests.myRequest2.Response.Array }}
    <li>{{ $.Tools.Decrypt (.Get "value").String }}</li>
{{ end }}
<ul>

Sample

A simple save and read sample using kvs api.

<form method="post">
    <input type="hidden" name="KvsApi.Api.Post.myRequest1"
           value="api/v1/entry">
    
    <input type="hidden" name="KvsApi.Api.Encrypt.myRequest1.<-key@{order(1)}"
            value="Variables.someKey" data-preset-val="Variables.someKey">

    <input type="hidden" name="KvsApi.Api.Json.myRequest1.string.topicId"
        value="5afb02fc-04a5-4524-a816-4381f97b1few">
    <input type="hidden" name="KvsApi.Api.Json.myRequest1.string.key"
        value="my-value">
    <input type="text" name="KvsApi.Api.Json.myRequest1.string.value@{order(2);encrypt}"
        value="some value to be encoded">

    <button type="submit">send</button>
</form>


<input type="hidden" name="KvsApi.Api.Get.myRequest2|2"
	value="api/v1/entry" data-preset-val="api/v1/entry">

<input type="hidden" name="KvsApi.Api.Encrypt.myRequest2.<-key"
    value="Variables.someKey" data-preset-val="Variables.someKey">

<input type="hidden" name="KvsApi.Api.Query.myRequest2.topicId"
    value="1" data-preset-val="5afb02fc-04a5-4524-a816-4381f97b1few">

<br>

<ul>
{{ range .Data.KvsApi.Api.Requests.myRequest2.Response.Array }}
    <li>{{ $.Tools.Decrypt (.Get "value").String }}</li>
{{ end }}
<ul>

@toArrIndexParameters

Can be used to generate multiple request parameters based on input array from another dynamic result.

For use cases where the api expects indexed requests.

For example

someValue0, someValue1 etc
someParam[0], someParam[1]

Use %v in the field name to indicate where the index is to be printed.

<input name="PIMApi.Api.Query.myRequest2.<-myField[%v]|@{toArrIndexParameters}" data-preset-val="myRequest1.Response.#.id">

In this example lets assume the result would be [1,2,3], this would be generated as 3 separate parameters for the request:

PIMApi.Api.Query.myRequest2.myField[0] = 1 PIMApi.Api.Query.myRequest2.myField[1] = 2 PIMApi.Api.Query.myRequest2.myField[2] = 3

@wrap

Basically wraps entire content into provided suffix and prefix. This function always accepts two parameters, if there is more or less than two, then initial value is returned.

<input type="hidden" name="KvsApi.Api.Json.test.string.field1@{wrap([,])}" value='"one","two","three"'>
<!-- Result: ["one","two","three"] -->

@prefix

Prefixes the value with given string.

<input type="hidden" name="KvsApi.Api.Json.test.string.field1@{prefix(hello )}" value='world'>
<!-- Result: hello world -->

@suffix

Adds suffix to the value.

<input type="hidden" name="KvsApi.Api.Json.test.string.field1@{prefix( world)}" value='hello'>
<!-- Result: hello world -->

CAFA with dynamics

Starting from version 1.201.1, GoErp allows to make json modifications more flexible, like predefined json, connecting json parts from another request and many more.

Let’s take CAFA as an example, as it will use most of the modification options.

<h1>Dynamic CAFA</h1>
<form method="post">

  <!-- Defining variables to get rid of very long value definitions in the inputs -->
  {{ $putConfBody := .Data.CaFaApi.Api.Requests.putConf.Json }}
  {{ $getConfStateResp := .Data.CaFaApi.Api.Requests.getConfState.Response.Get "0" }}
  
  <!-- 1. Load initial state of the configuration -->
  <input type="hidden" name="CaFaApi.Api.Get.getConfState" value="configuration" data-preset-val="configuration">
  
  <!-- 2. provide query parameters for the configuration entry -->
  <input type="hidden" name="CaFaApi.Api.QueryBulk.getConfState.url" value="application=my-app&level=Company&name=dynamic-sample"
         data-preset-val="application=my-app&level=Company&name=dynamic-sample">

  <!-- 3. Create save call with initial json payload -->
  <input type="hidden" name="CaFaApi.Api.Put.putConf|1" value="v3/configuration">
  {{ if $getConfStateResp.Exists }}
  <input type="hidden" name="CaFaApi.Api.Json.putConf.json.<-@{order(1)}"
         value="getConfState.Response.0">
  {{ else }}
  <input type="hidden" name="CaFaApi.Api.Json.putConf.json.@{order(1)}"
         value='{"application":"my-app","level":"Company","name":"dynamic-sample","value":{}}'>
  {{ end }}

  <!-- 4. Optionally, check updated state of the entry. -->
  <input type="hidden" name="CaFaApi.Api.Get.getConfFinal|2" value="v3/configuration/{id}">
  <input type="hidden" name="CaFaApi.Api.Path.getConfFinal.<-id" value="putConf.Response.id">

  <!-- 5. Modify section, use same approach to as many fields as needed -->
  <br/>Cat Murka age: 
  <input type="text" 
         name="CaFaApi.Api.Json.putConf.number.value.pets.murka.age"
         value='{{ if $putConfBody.Exists }}{{ $putConfBody.Get "value.pets.murka.age" }}{{ else}}{{ $getConfStateResp.Get "value.pets.murka.age" }}{{ end }}'>

  <button type="submit">Submit</button>
</form>

<h2>Before update</h2>
Murka age: {{ .Data.CaFaApi.Api.Requests.getConfState.Response.Get "0.value.pets.murka.age" }}

<h2>After update</h2>
Murka age: {{ .Data.CaFaApi.Api.Requests.getConfFinal.Response.Get "0.value.pets.murka.age" }}
  1. Firstly, we need to have current state of the configuration, so we are loading it from the api with getConfState request. Later we can use it for the “first page loading” event to display current state.
  2. Here, with QueryBulk parameter type we are preparing a bunch of static query parameters to get specific configuration entry, based on application, level and name.
  3. This part of the code allows as to prepare initial body of the json payload. We are getting the payload from getConfState if it is available, or generating totally new one otherwise. Also, we are using input functions feature to define the order for json input parameters. By default, GoErp receives all parameters in a chaotic order. But we need to be sure that initial structure assignment goes always first, so we are defining order by setting it to 1. Additionally, we need to assign the payload to body as a root object. To do that, just omit key definition after the dot (with key: .json.key, and without: .json.).
  4. Optionally, we are defining request getConfFinal to retrieve configuration updated state, just to see that configuration was changed. In most cases it is not needed in real applications.
  5. In this section we are making modifications to the configuration object and making sure that UI displays correct value for each case: 1) first load of the page and 2) after update event. We know, that if json body of the put request is available, then we came to this page right after save, otherwise it is first loading. Variables were defined just to make input value look smaller.

The final working example with a couple more fields after removing all unnecessary stuff:

<!-- Vars section -->
{{ $putConfBody := .Data.CaFaApi.Api.Requests.putConf.Json }}
{{ $getConfStateResp := .Data.CaFaApi.Api.Requests.getConfState.Response.Get "0" }}

<h1>Dynamic CAFA</h1>
<form method="post">
    <input type="hidden" name="CaFaApi.Api.Get.getConfState" value="configuration" data-preset-val="configuration">
    <input type="hidden" name="CaFaApi.Api.QueryBulk.getConfState.url" value="application=my-app&level=Company&name=dynamic-sample" data-preset-val="application=my-app&level=Company&name=dynamic-sample">

    <input type="hidden" name="CaFaApi.Api.Put.putConf|1" value="v3/configuration">
    {{ if $getConfStateResp.Exists }}
    <input type="hidden" name="CaFaApi.Api.Json.putConf.json.<-@{order(1)}" value="getConfState.Response.0">
    {{ else }}
    <input type="hidden" name="CaFaApi.Api.Json.putConf.json.@{order(1)}" value='{"application":"my-app","level":"Company","name":"dynamic-sample","value":{}}'>
    {{ end }}

    <input type="hidden" name="CaFaApi.Api.Get.getConfFinal|2" value="v3/configuration/{id}">
    <input type="hidden" name="CaFaApi.Api.Path.getConfFinal.<-id" value="putConf.Response.id">

    <!-- Modify section -->
    <br/>Cat Murka age: <input type="text" name="CaFaApi.Api.Json.putConf.number.value.pets.murka.age"
        value='{{ if $putConfBody.Exists }}{{ ($putConfBody.Get "value.pets.murka.age").String }}{{ else}}{{ ($getConfStateResp.Get "value.pets.murka.age").String }}{{ end }}'>
    <br/>Cat Murka breed: <input type="text" name="CaFaApi.Api.Json.putConf.string.value.pets.murka.breed"
        value='{{ if $putConfBody.Exists }}{{ ($putConfBody.Get "value.pets.murka.breed").String }}{{ else}}{{ ($getConfStateResp.Get "value.pets.murka.breed").String }}{{ end }}'>
    <br/>Cat Murka kind: <input type="text" name="CaFaApi.Api.Json.putConf.string.value.pets.murka.kind"
        value='{{ if $putConfBody.Exists }}{{ ($putConfBody.Get "value.pets.murka.kind").String }}{{ else}}{{ ($getConfStateResp.Get "value.pets.murka.kind").String }}{{ end }}'>


    <br/><button type="submit">Submit </button>
</form>

Form drafts

It’s possible to instruct the form to keep certain dynamic api parameters for api requests but not trigger the corresponding api request with the post until instructed to do so.

This can be used to make ‘drafts’ from inputs that will not trigger the api calls.

Hold all api request

The following form entry will prevent all dynamic api calls from triggering, but keeps all the parameters intact.

Note that this also affects api calls that are done by presets.

Server will not run any dynamic api calls when the value of the Form.HoldDraft is true. Inputs against these calls can be changed and posted without the actual calls being initiated.

<form method="post">
        <input type="hidden" id="req" name="ErplyApi.Api.Post.myRequest1"
            value="getProducts">

        <label for="id">Records on page</label>
        <input type="text" id="id" name="ErplyApi.Api.PostParam.myRequest1.recordsOnPage"
            value="{{ index .Data.Parameters "ErplyApi.Api.PostParam.myRequest1.recordsOnPage" }}">

        <label for="hold-state">Hold draft</label>
        <input type="checkbox" id="hold-state" name="Form.HoldDraft" value="true">

        <button type="submit">send</button>
    </form>

Hold specific api calls

In order the enrich data with additional possible api calls but hold others as drafts we can use a different input.

All dynamic requests that have the names will not be executed.

<input type="text" id="hold-state" name="Form.HoldDraftFor" value="myRequest2,myRequest3">

Can possibly make stepper type templates using this method. In this sample the same ‘Send’ will run request1 and request2 in sequence.

<form method="post">

        <input type="hidden" id="req" name="ErplyApi.Api.Post.myRequest1"
            value="getProducts">
        <input type="hidden" id="req" name="ErplyApi.Api.Post.myRequest2"
            value="getProducts">
        

        <label for="id">Records on page</label>
        <input type="text" id="id" name="ErplyApi.Api.PostParam.myRequest1.recordsOnPage"
            value="{{ index .Data.Parameters "ErplyApi.Api.PostParam.myRequest1.recordsOnPage" }}">

        <label for="id">Records 2 on page</label>
        <input type="text" id="id" name="ErplyApi.Api.PostParam.myRequest2.recordsOnPage"
                    value="{{ index .Data.Parameters "ErplyApi.Api.PostParam.myRequest2.recordsOnPage" }}">

        {{ if ne .Data.ErplyApi.Api.Requests.myRequest1.Response.Exists true }}
            <input type="text" id="hold-state" name="Form.HoldDraftFor" value="myRequest2">
        {{ end }}

        <button type="submit">send</button>
    </form>

Making FTP requests

Since v1.277.0 GoErp supports FTP requests in dynamics. This feature allows to upload or download files to/from servers through FTP protocol.

Input parameters

Initialization of the request is made through FTPGet, FTPPut types. Like so: <input type="hidden" name="CustomApi.Api.FTPPut.file" value="localhost:2121">. Value in this case contains the FTP server host with port. Port is mandatory parameter. If provider not specified the port, then the default ports for ftp 21,2121, and for ftps 990.

All request parameters should be provided through PostParam type. The following parameters are available:

  • file - content of the file to upload.
  • username - FTP server username.
  • password - FTP server password.
  • path - Path to the file on the server. Example: /path/to/file.txt.

Response body

In case of FTPGet requests, the response body will contain the file content.

{
  "status": "success",
  "base64File": "base64 encoded file content"
}

In case of FTPPut requests, just a status message will be returned.

{
  "status": "success"
}

Both methods have the same error response structure.

{
  "error": "550 Could not access file: open /tmp/my-data: no such file or directory",
  "status": "error"
}

Example

<h2>Upload file</h2>
<form method="post" enctype="multipart/form-data">
    <input type="hidden" name="CustomApi.Api.FTPPut.file" value="localhost:2121">
    <input type="hidden" name="CustomApi.Api.PostParam.file.username" value="test">
    <input type="hidden" name="CustomApi.Api.PostParam.file.password" value="test">
    <br>Path: <input type="text" name="CustomApi.Api.PostParam.file.path" value="">
    <br>File: <input type="file" name="CustomApi.Api.PostParam.file.file" value="">
    <button type="submit">Go</button>
</form>

<h2>Download file</h2>
<form method="post">
    <input type="hidden" name="CustomApi.Api.FTPGet.gFile" value="localhost:2121">
    <input type="hidden" name="CustomApi.Api.PostParam.gFile.username" value="test">
    <input type="hidden" name="CustomApi.Api.PostParam.gFile.password" value="test">
    <br>Path: <input type="text" name="CustomApi.Api.PostParam.gFile.path" value="">
    <button type="submit">Go</button>
</form>

<br>Response:<pre>{{ .Data.CustomApi.Api.Requests.gFile.Response.Raw | jsonPretty }}</pre>

Form control

Always used with forms. Goerp application handles all pages with one dynamic handler and pages may have multiple forms. When the page is processed by the handler, then all forms and related input models are involved. We need to know the exact model to be sure that after submitting the form POST request, the right model gets processed. This model name should be passed with the postActionEntity input name.

All form control fields:

  • postAction - what action should be applied to the entity, currently there is only one option - delete, if passed then form control should also have postActionIds input included. If postAction is not specified, then by default applied save/update action.
  • postActionIds - entity ID’s separated by comma (,). Those are input parameters for performing changes under specific entities (in most cases deleting them)
  • postActionEntity - defines the model name which is related to the form. Needed only for the save, update and delete options (not needed in query type forms). Supports multiple entities, each of them should be defined in separate input with the same name, check Multi-entity form topic in this section.
  • postActionRedirect - defines the link where to redirect after successful post action (not applied to delete action and to forms with GET method)

Subsections of Form control

Simple actions with form

GET for fetching data based on query criteria

Note

Do not use any form control inputs while submitting the form using GET method (at least for now there is no practical use of form control options in GET requests).

Use GET requests to fetch multiple data for tables using Query model, or to fetch single Input entity (usually just passing its ID) while defining link to the edit form.

Query models always connected with the List models. When defining the List model in your template, GOERP will make request to the related API and fetch data based on the Query parameters.

<form method="get" id="query-form">
  <label for="query-parameter-1">Query parameter 1:</label>
  <input type="text" id="query-parameter-1" name="Api.ModelQuery.Field"
         value="{{ .Data.Api.ModelQuery.Field }}">

  <label for="query-parameter-2">Query parameter 2:</label>
  <input type="text" id="query-parameter-2" name="Api.ModelQuery.AnotherField"
         value="{{ .Data.Api.ModelQuery.AnotherField }}">

  <button type="submit">Find</button>
</form>

<!-- Use List entities to actually apply the query, otherwise Query parameters will be ignored -->
<ul>
  {{ range .Data.Api.ModelList }}
  <li>{{ .Name }}</li>
  {{ end }}
</ul>

POST for create and update actions

Let’s take a look into simple create/update example form:

<form method="post" id="post-form">
  <!-- For saving action need to pass only postActionEntity, which will ensure that you are working with correct model -->
  <input type="hidden" name="postActionEntity" value="ModelInput">

  <!-- Optionally, use redirect option to redirect to the specified page after successful saving -->
  <input type="hidden" name="postActionRedirect" value="https://erply.com/">

  <!-- Next 2 fields are related to actual model parameters -->
  <label for="input-1">Input 1:</label>
  <input type="text" id="input-1" name="Api.ModelInput.Field"
         value="{{ .Data.Api.ModelInput.Field }}">

  <label for="input-2">Input 2:</label>
  <input type="text" id="input-2" name="Api.ModelInput.AnotherField"
         value="{{ .Data.Api.ModelInput.AnotherField }}">

  <button type="submit" id="save">Save</button>
</form> 

Often we need to make reference from the list of rows to the specific entity edit form. In this case pass entity ID from the selected row to the edit link by attaching it to the Input ID (some input models doesn’t have Input suffix, but you can check in data source definitions in editor if model have “write” option):

<!-- Use built-in .Session object to access account related configuration -->
<!-- Use  prefix '$' if linking made inside the range loop, otherwise omit it -->
<a href="/{{ $.Session.ClientCode }}/{{ $.Session.Language.Code }}/example-page?Api.ModelInput.Id={{ .Id }}"
   target="_self" class="table-action-button">
  <i class="button nowrap  button--transparent icon-Edit-Line"></i>
</a>

POST for delete action

Delete actions should be made by following next steps:

  • Form must have method parameter set to POST
  • Inside the form set hidden postAction input to delete
  • Inside the form set hidden postActionIds input to entity IDs (separated by comma) that should be deleted (using custom JS or predefined goerp components)
  • Inside the form set hidden postActionEntity input to the entity name that should be deleted. If it is row in the table, then that entity should have suffix List (e.g. WarehouseDtoList or WarehouseDtoList). If deleting from the single entity edit form, then suffix may be Input or just model name, check data sources definition in the editor for exact model names. Please note, postActionEntity value shouldn’t have API definition, just model name.
<form method="get" id="entity-table-id">
  <input type="hidden" name="postAction" value="delete">
  <input type="hidden" name="postActionIds" value="">
  <input type="hidden" name="postActionEntity" value="ModelList">
  <!-- Add submit button if delete modal not used -->
  <!-- <button type="submit">DELETE</button>-->
</form>

<!-- and then somewhere in the list range -->
<ul>
  {{ range .Data.Api.ModelList }}
  <li>{{ .Name }}</li>
  <!-- If used delete-confirm-partial component, then define next parameters inside the button -->
  <button data-form-id="entity-table-id"
          data-delete-identifier="{{ .Id }}"
          data-post-action-entity="ModelList"
          class="button button--transparent  nowrap table-action-button action-row-red form-delete-button">
    <i class="icon-Trash-Empty-NotFilled"></i>
  </button>
  {{ end }}
</ul>

<!-- As an option, somewhere in the end of the page import delete-confirm-modal from goerp components and use it -->
<!-- to delete one or many entities. Or write your own deletion logic -->
{{ template "delete-confirm-partial" .}}

Multi-entity form

GOERP now supports multiple entities definitions in one form, which allows to save data of many different, unrelated entities at once.

Note

Multi-entity form supports only one postAction type for all scoped entities. For example, it cannot be used to delete one and create another entity at once. Only delete for all related entities OR create/update for all related entities.

Let’s say we need to create warehouse and point of sale records by submitting them in one form, then code would look like this:

<form method="POST">
  
  {{/* Define entity names that should be processed */}}
  <input type="hidden" name="postActionEntity" value="WarehouseInput">
  <input type="hidden" name="postActionEntity" value="PointOfSale">

  {{/* Warehouse input data */}}
  <fieldset>
    <legend>Warehouse:</legend>
    <label for="name-eng">Name eng:</label>
    <input type="text" id="name-eng" name="AccountAdminApi.WarehouseInput.Name.en"
           value="{{ .Data.AccountAdminApi.WarehouseInput.Name.en }}">
    <label for="name-est">Name est:</label>
    <input type="text" id="name-est" name="AccountAdminApi.WarehouseInput.Name.et"
           value="{{ .Data.AccountAdminApi.WarehouseInput.Name.et }}">
  </fieldset>

  {{/* Point of sale input data */}}
  <fieldset>
    <legend>Point of sale:</legend>
    <label for="name-pos">Name:</label>
    <input type="text" id="name-pos" name="AccountAdminApi.PointOfSale.Name"
           value="{{ .Data.AccountAdminApi.PointOfSale.Name }}">
    <label for="name-shop">Shop name:</label>
    <input type="text" id="name-shop" name="AccountAdminApi.PointOfSale.ShopName"
           value="{{ .Data.AccountAdminApi.PointOfSale.ShopName }}">
  </fieldset>
</form>

Bulk-entity form

Some input entities in GOERP may be submitted as a bulk (having multiple records in scope of one form submit), allowing to create or update multiple records at once. Such inputs have array types in data source definition - []string (e.g. ErplyApi.ProductInSupplierPriceList). Also, bulk input fields may be a part of some complex input entities (e.g. ErplyApi.SalesDocumentInput) accepts many Row’s as part of the input.

Warning
  1. Every row in the bulk should always have same order of inputs as others (check samples).
  2. All rows in the bulk should always have same amount of inputs. For example, goerp will fail if one row contains Name and Code and second one have ID, Name and Code. If ID undefined, then pass empty value with an input.

Bulk-entity form sample

<form method="POST">

   {{/* Define entity name that should be processed */}}
   <input type="hidden" name="postActionEntity" value="ProductInSupplierPriceList">

   {{/* Empty row (to add new) */}}
   <fieldset>
      <legend>Create row:</legend>
      <label for="spl-id">Supplier price list ID:</label>
      <input type="text" id="spl-id" 
             name="ErplyApi.ProductInSupplierPriceList.SupplierPriceListID">
      <label for="product-id">Product ID:</label>
      <input type="text" id="product-id" 
             name="ErplyApi.ProductInSupplierPriceList.ProductID">
   </fieldset>

   <h2>Existing rows</h2>
   {{/* Populate existing rows for update */}}
   {{ range $i, $el := .Data.ErplyApi.ProductInSupplierPriceListList }}
   <fieldset>
      <legend>Row {{$i}}:</legend>
      <label for="spl-id-{{$i}}">Supplier price list ID:</label>
      <input type="text" id="spl-id-{{$i}}"
             name="ErplyApi.ProductInSupplierPriceList.SupplierPriceListID"
             value="{{$el.SupplierPriceListID}}">
      <label for="product-id-{{$i}}">Product ID:</label>
      <input type="text" id="product-id-{{$i}}"
             name="ErplyApi.ProductInSupplierPriceList.ProductID"
             value="{{$el.ProductID}}">
   </fieldset>
   {{ end }}
</form>

Subsections of Bulk-entity form

Sales document sample

<!-- Display possible api errors -->
<div>
  {{ range .Data.Errors }}
  <span>{{ . }}</span>
  {{ end }}
</div>

<form method="post">
  <!-- Field used by goErp to state what we want to save -->
  <input type="hidden" name="postActionEntity" value="SalesDocumentInput"/>

  <!-- Need to set the ID of the existing document -->
  <input type="hidden" name="ErplyApi.SalesDocumentInput.ID"
         value="{{ .Data.ErplyApi.SalesDocumentInput.ID }}">

  <table>
    <thead>
    <tr>
      <th>Product ID</th>
      <th>Amount</th>
      <th>Price</th>
    </tr>
    </thead>

    <tbody>
    {{ range $row := .Data.ErplyApi.SalesDocument.Rows }}
    <tr>
      <td>
        <!-- Stable row id is used by erply api to determine existing rows
        the api always replaces all rows so adding a single row means all rows need to be resaved -->
        <input type="hidden" name="ErplyApi.SalesDocumentInput.RowStableRowID"
               value="{{ $row.StableRowID }}">
        <input name="ErplyApi.SalesDocumentInput.RowProductID" value="{{ $row.ProductID }}">
      </td>
      <td><input name="ErplyApi.SalesDocumentInput.RowAmount" value="{{ $row.Amount }}"></td>
      <td><input name="ErplyApi.SalesDocumentInput.RowPrice" value="{{ $row.Price }}"></td>
    </tr>
    {{ end }}
    <tr>
      <td>
        <input type="hidden" name="ErplyApi.SalesDocumentInput.RowStableRowID" value="0">
        <input name="ErplyApi.SalesDocumentInput.RowProductID" value="" placeholder="Product ID">
      </td>
      <td><input name="ErplyApi.SalesDocumentInput.RowAmount" value="0"></td>

      <!-- Manual pricing allowed? -->
      <td><input name="ErplyApi.SalesDocumentInput.RowPrice" value="0"></td>
    </tr>
    </tbody>
  </table>

  <!-- Erply api returns this boolean as a string 0 or 1 for some reason -->
  {{ if eq .Data.ErplyApi.SalesDocument.Confirmed "1" }}
  <h2>Confirmed documents cannot be edited</h2>
  {{ else }}
  <button type="submit">Save</button>
  {{ end }}
</form>

Session

Contains service information, like session key, client code, etc… Syntax: {{ .Session.ClientCode }} Full list of parameters:

.Session.ClientCode
.Session.SessionKey

.Session.User.ID
.Session.User.FullName
.Session.User.EmployeeID
.Session.User.EmployeeName
.Session.User.GroupID
.Session.User.BOLoginUrl // Back office url for current user

<!-- Services May be extended in feature -->
.Session.Services.ErplyApi.URL
.Session.Services.Auth.URL
.Session.Services.AccountAdmin.URL
.Session.Services.Assignments.URL
.Session.Services.CaFa.URL
.Session.Services.PointOfSaleApi.URL
.Session.Services.UserApi.URL
.Session.Services.WMSApi.URL
.Session.Services.CrmApi.URL
.Session.Services.PricingApi.URL
.Session.Services.PimApi.URL
.Session.Services.VinApi.URL
.Session.Services.InventoryTransactionApi.URL
.Session.Services.InventoryDocumentApi.URL
.Session.Services.IntLog.URL
.Session.Services.ReportsApi.URL
.Session.Services.WebhookManager.URL
.Session.Services.EMSApi.URL
.Session.Services.EInvoiceApi.URL
.Session.Services.KvsApi.URL

.Session.Language.Code
.Session.DefaultLanguage.Code

.Session.AccountConfiguration.configurations

Some notes about specific parameters:

  • .Session.AccountConfiguration.configurations contains all erply configuration parameters
  • .Session.Language.Code two letter code of the currently set account language
  • .Session.DefaultLanguage.Code two letter code of the default account language

Parameters

Not controlled by goerp, can be used to mirror some data after submitting the form. Basically, goerp will return all assigned parameters data with the response.

Let’s say we want to pass some data to the next page, using regular query parameter:

<a href="/{{ $.Session.ClientCode }}/{{ $.Session.Language.Code }}/example-page?my-custom-param=hello_there"></a>

And on the referenced page we can call this parameter using .Data.Parameters

<h1>Say Hi!</h1>
<p>{{ .Data.Parameters.my-custom-param }}</p>

Subsections of Parameters

Validation

It’s possible to instruct the server to validate incoming parameters to certain rules.

Register the rules on page “Form validation” configuration section.

Form validation Form validation

Enable and disable feature

You can use the toggle button to disable or enable the entered rules.

Rules

The parameter name is the full parameter name to be validated.

Type can be any of the following:

  1. required - checks that the parameter exists
  2. type - currently can have the value of a number, checks if the parameter is a valid number
  3. min - checks that the numeric value is higher or equal to the given value
  4. max - checks that the numeric value is lower or equal to the given value
  5. minLength - checks that the string value is at-least the given characters long
  6. maxLength - checks that the string value does not exceed the given amount of characters
  7. pattern - checks if the input is valid to the regex pattern (ex: .{8,})

Validation

The validation is completed for each rule and the errors are returned in the regular .Data.Errors array.

{{ range .Data.Errors }}
    {{ . }}
{{ end }}

Version

Holds current version of the goerp server, usage: {{ .Version }}

Preset queries

Goerp have an option to define Preset queries, which will be saved into the template data and executed every time when page loaded without passing any form data. Usually preset option is used while querying the table of records. However, inputs also may have preset option in very specific cases. Check data source definitions in editor to ensure if model supports presets or not.

To use presets, just add input tags somewhere in the template, but outside any form, and set desired values:

<body>
<input type="hidden" name="Preset.Api.ModelQuery.FieldOne" value="1">
<input type="hidden" name="Preset.Api.ModelQuery.FieldTwo" value="two">

<!-- When template loaded from the store, goerp already knows the filter criteria based on presets -->
<!-- and this list will contain records that are corresponds to preset query inputs -->
<ul>
  {{ range .Data.Api.ModelList }}
  <li>{{ .Name }}</li>
  {{ end }}
</ul>
</body>
Warning

Define all Preset inputs outside the form, so they will not mess up with form data (In most cases goerp editor will throw an error during saving). That make sense, because they are not related to the form at all, consider them as “imports” in javascript, so write them somewhere in the beginning of the document (page or partial, in case of partial inside “content-block”)

Chaining models

Tip

Despite dynamics, models have very restricted options for data chaining. Switching to the dynamic data model is strongly recommended, except cases when using model approach is more convenient or dynamic model not usable at all. But keep in mind, models are significantly less flexible in comparison with dynamics.

Chain models with in-build data sources

Currently, models supports chaining with in-built data sources only. Those are: Session, Storage and Parameters.

Note

If source doesn’t contain data, then chaining would be skipped and target parameter would be set to the empty value.

Tip

There is also option to save data from data models to the Storage. Check storage guides for more information.

Session

<!-- in presets -->
<input name="Preset.ErplyApi.SalesDocumentQuery.ClientID<-" value="Session.customer.ID">
<!-- inside the form -->
<form method="post">
  <input name="ErplyApi.SalesDocumentQuery.ClientID<-" value="Session.customer.ID">
</form>

Storage

<!-- in presets, expects that storage have the value, otherwise chaining would be skipped -->
<input name="Preset.ErplyApi.SalesDocumentQuery.ClientID<-" value="Storage.customerID">
<!-- inside the form, expects that storage have the value, otherwise chaining would be skipped -->
<form method="post">
  <input name="ErplyApi.SalesDocumentQuery.ClientID<-" value="Storage.customerID">
</form>

Parameters

<!-- in presets, expects that storage have the value, otherwise chaining would be skipped -->
<input name="Preset.ErplyApi.SalesDocumentQuery.ClientID<-" value="Parameters.customerID">
<!-- inside the form, expects that storage have the value, otherwise chaining would be skipped -->
<form method="post">
  <input name="ErplyApi.SalesDocumentQuery.ClientID<-" value="Parameters.customerID">
</form>

Request statistics

Response data now contains some statistics of performed request. Current structure of the .Data.RequestStats parameter is:

RequestStats.TotalTime (Total time spent by performing entire request, including parsing and api calls)
RequestStats.TemplateProcessingTime (Time spent on template preparation and processing)
RequestStats.APICallsTotalTime (Total time of API calls, for DTO may exceed Total request time because some requests performed in parallel)
RequestStats.APICallsCount (Number of API calls)
RequestStats.APICalls[] (Array containing APICall elements)
RequestStats.CacheTotalTime (Total time spent on caching, not available at the moment)

APICall.Took (Time spent on this particular call, without parsing)
APICall.Endpoint (API endpoint)
APICall.ResponseCode (API http response code)

App

Contains basic connected application information

{{ .App.UUID }}
{{ .App.Name }}
{{ .App.Version }}
{{ .App.CreatedAt }} <!-- unix timestamp -->
{{ .App.UpdatedAt }} <!-- unix timestamp -->

Subsections of Built-in helper functions

Strings

Name Description Function arguments Usage
replaceAll Replace all occurrences of the input in the text replaceAll(myText, lookFor, replaceWith) {{ replaceAll “test-string-test” “test” “new” }}
replace Replace the input text occurrences the given amount of times replace(myText, lookFor, replaceWith, times) {{ replace “test-string-test” “test” “new” 1 }}
repeat Repeat the given string the input amount of times repeat(myText, times) {{ repeat “my-string” 10 }}
trimSpace Remove trailing spaces trimSpace(myText) {{ trimSpace " my-StRing " }}
containsStr Check if string contains substring containsStr(str, part) bool {{ containsStr “my-StRing” “my” }}
startsStr Check if string have prefix startsStr(str, prefix) bool {{ startsStr “my-StRing” “my” }}
endsStr Check if string have suffix endsStr(str, suffix) bool {{ endsStr “my-StRing” “ing” }}
toLower Convert all characters to lower case toLower(myText) {{ toLower “my-StRing” }}
toUpper Convert all characters to upper case toUpper(myText) {{ toUpper “my-StRing” }}
toTitle Convert the text to title case toTitle(myText) {{ toTitle “my-StRing” }}
split Convert a string to a string array split(myText, separator) {{ split “one,two” “,” }}
splitN Convert a string to a string array with max splits splitN(myText, separator, times) {{ split “one,two,three” “,” 1 }}
hash Hash encoding, supported types: md5, sha1, sha256, sha512 hash(str, type) string {{ hash “my-StRing” “sha256” }}
encode Basic encoding, supported types: base32, base64, hex encode(str, type) string {{ encode “my-StRing” “base64” }}
decode Basic decoding, supported types: base32, base64, hex decode(str, type) string {{ decode “my-StRing” “base64” }}
hmac HMAC encoding, supported types: md5, sha1, sha256, sha512 hmac(str, key, type) string {{ hmac “my-StRing” “sec-key” “sha256” }}
html encapsulates a known safe HTML document fragment html(htmlStr) string {{ html “
trusted html
” }}
htmlAttr encapsulates an HTML attribute from a trusted source htmlAttr(htmlAttrStr) string {{ htmlAttr `dir=“ltr”` }}
urlSrc encapsulates a known safe URL or URL substring urlSrc(urlStr) string {{ urlSrc “/trusted/url” }}
uuid generates UUID V4 uuid() string {{ uuid }}
transformToCp Converts UTF-8 string to the one of desired code page options transformToCp(source, codepage) string {{ transformToCp “¡¢£” “iso8859_1” }}

The functions that accept a single parameter can also be used with the pipe notation.

<p>{{ "my-StRing" | toLower | trimSpace }}</p>
<!-- Results in: "my-string" -->

Supported code pages for transformToCp

Code page
cp037
cp437
cp850
cp852
cp855
cp858
cp860
cp862
cp863
cp865
cp866
cp1047
iso8859_1
iso8859_2
iso8859_3
iso8859_4
iso8859_5
iso8859_6
iso8859_7
iso8859_8
iso8859_9
iso8859_10
iso8859_13
iso8859_14
iso8859_15
iso8859_16
koi8r
koi8u
macintosh
macintoshcyrillic
windows874
windows1250
windows1251
windows1252
windows1253
windows1254
windows1255
windows1256
windows1257
windows1258

Dates

The date formatting feature uses the go date formatting, as such the same format layouts are expected. Check https://gosamples.dev/date-time-format-cheatsheet/ for information on how the form the formats. Default format 2006-01-02T15:04:05Z07:00. Some dt prefixed functions returns timeObject, which is golang struct. This struct may be used while calling multiple functions in a pipe, check examples for how-to-use tips. All timeObject related functions description could be found on the official golang documentation portal. Check all functions that starts from func (t Time).

Almost every account have default time related configurations, like timezone and formats. Timezone may not exist for accounts that shares many timezones (e.g. most of the USA accounts), in this case timezone should be configured by account owners.

Available variables:

{{ $timeZone := .Data.ErplyApi.ConfigurationList.timezone }} <!-- Europe/Tallinn -->
{{ $dateFormat := .Data.ErplyApi.ConfigurationList.go_date_format }} <!-- 02.01.2006 -->
{{ $monthDayFormat := .Data.ErplyApi.ConfigurationList.go_month_day_format }} <!-- 02.01 -->
{{ $timeFormat := .Data.ErplyApi.ConfigurationList.go_time_format }} <!-- 15:04:05 -->
{{ $hourMinuteFormat := .Data.ErplyApi.ConfigurationList.go_hour_minute_format }} <!-- 15:04 -->
{{ $dateTimeFormat := .Data.ErplyApi.ConfigurationList.go_date_time_format }} <!-- 02.01.2006 15:04:05 -->

Duration formatting syntax

The duration formatting feature partially inherits the go time formatting, so those are placeholders:

  • 00 - years
  • 01 - months
  • 02 - days
  • 03 - hours
  • 04 - minutes
  • 05 - seconds

Sample usage:

{{ formatDuration "2450000m35s" "00 year(s) 01 month(s) 02 day(s) 03 hour(s) 04 minute(s) and 05 second(s)" }}
<!-- Result: 4 year(s) 8 month(s) 1 day(s) 9 hour(s) 20 minute(s) and 35 second(s) -->
Name Description Function arguments Usage
unixToDate Convert a unix value to the requested date format in the selected timezone unixToDate(unix, timeZone, format) string {{ unixToDate 150000 “UTC” “2006-01-02” }}
formatDate Convert one specific date format to another formatDate(dateValue, fromFormat, toFormat, timeZone) string {{ formatDate “2022-11-11” “2006-01-02” “2006/02/01” “GMT” }}
serverTime Returns unix time in seconds serverTime() int64 {{ serverTime }}
serverTimeIn Returns current datetime in provided timezone and format serverTimeIn(timeZone, format) string {{ serverTimeIn “GMT” “2006-01-02” }}
addDate Adds amount of unitOfTime to the datetime and returns datetime in default format addDate(datetime, unitOfTime, amount) string {{ addDate “2023-01-02” “month” -6 }}
addDate same as previous, but with optional format parameter addDate(datetime, unitOfTime, amount, format) string {{ addDate “2023-01-02” “month” 6 “2006/02/01” }}
dtFromDateTime To get timeObject from the datetime dtFromDateTime(datetime, format, timezone) timeObject {{ dtFromDateTime “2023-01-02” “2006-01-02” “GMT” }}
dtCurrent Current time (returns default format) dtCurrent() timeObject {{ dtCurrent }}
dtCurrentIn Current time in TZ (returns default format) dtCurrentIn(timezone) timeObject {{ dtCurrentIn “GMT” }}
dtAdjustDate add or subtract unit of time to/from datetime dtAdjustDate(timeObject, unitOfTime, amount) timeObject {{ dtAdjustDate timeObject “month” 6}}
dtFirstDayOfWeek First day of week, if isSunday true then week starts from Sunday dtFirstDayOfWeek(timeObject, isSunday) timeObject {{ dtFirstDayOfWeek timeObject false }}
dtLastDayOfWeek Last day of week, if isSunday true then week starts from Sunday dtLastDayOfWeek(timeObject, isSunday) timeObject {{ dtLastDayOfWeek timeObject false }}
dtFirstDayOfMonth First day of month dtFirstDayOfMonth(timeObject) timeObject {{ dtFirstDayOfMonth timeObject }}
dtLastDayOfMonth Last day of month dtLastDayOfMonth(timeObject) timeObject {{ dtLastDayOfMonth timeObject }}
dtFirstDayOfYear First day of the year dtFirstDayOfYear(timeObject) timeObject {{ dtFirstDayOfYear timeObject }}
dtLastDayOfYear Last day of the year dtLastDayOfYear(timeObject) timeObject {{ dtLastDayOfYear timeObject }}
dtToFormat Convert timeObject to string using format dtToFormat(timeObject, format) string {{ dtToFormat timeObject “2006/02/01” }}
dtToTimezone Set timeObject into a different timezone dtToTimezone(timeObject, timezone) timeObject {{ dtToTimezone timeObject “UTC” }}
dtToFormatIn Set timezone and format at the same time dtToFormatIn(timeObject, format, timezone) string {{ dtToFormatIn timeObject “2006/02/01” “UTC” }
dtFirstDayOfPreviousMonthInFormat Datetime manipulation in format, returns string dtFirstDayOfPreviousMonthInFormat(timeObject, format) string {{ dtFirstDayOfPreviousMonthInFormat timeObject “2006/02/01” }}
dtFirstDayOfCurrentMonthInFormat Datetime manipulation in format, returns string dtFirstDayOfCurrentMonthInFormat(timeObject, format) string {{ dtFirstDayOfCurrentMonthInFormat timeObject “2006/02/01” }}
dtFirstDayOfNextMonthInFormat Datetime manipulation in format, returns string dtFirstDayOfNextMonthInFormat(timeObject, format) string {{ dtFirstDayOfNextMonthInFormat timeObject “2006/02/01” }}
dtLastDayOfPreviousMonthInFormat Datetime manipulation in format, returns string dtLastDayOfPreviousMonthInFormat(timeObject, format) string {{ dtLastDayOfPreviousMonthInFormat timeObject “2006/02/01” }}
dtLastDayOfCurrentMonthInFormat Datetime manipulation in format, returns string dtLastDayOfCurrentMonthInFormat(timeObject, format) string {{ dtLastDayOfCurrentMonthInFormat timeObject “2006/02/01” }}
dtLastDayOfNextMonthInFormat Datetime manipulation in format, returns string dtLastDayOfNextMonthInFormat(timeObject, format) string {{ dtLastDayOfNextMonthInFormat timeObject “2006/02/01” }}
dtFirstDayOfPreviousYearInFormat Datetime manipulation in format, returns string dtFirstDayOfPreviousYearInFormat(timeObject, format) string {{ dtFirstDayOfPreviousYearInFormat timeObject “2006/02/01” }}
dtFirstDayOfCurrentYearInFormat Datetime manipulation in format, returns string dtFirstDayOfCurrentYearInFormat(timeObject, format) string {{ dtFirstDayOfCurrentYearInFormat timeObject “2006/02/01” }}
dtFirstDayOfNextYearInFormat Datetime manipulation in format, returns string dtFirstDayOfNextYearInFormat(timeObject, format) string {{ dtFirstDayOfNextYearInFormat timeObject “2006/02/01” }}
dtLastDayOfPreviousYearInFormat Datetime manipulation in format, returns string dtLastDayOfPreviousYearInFormat(timeObject, format) string {{ dtLastDayOfPreviousYearInFormat timeObject “2006/02/01” }}
dtLastDayOfCurrentYearInFormat Datetime manipulation in format, returns string dtLastDayOfCurrentYearInFormat(timeObject, format) string {{ dtLastDayOfCurrentYearInFormat timeObject “2006/02/01” }}
dtLastDayOfNextYearInFormat Datetime manipulation in format, returns string dtLastDayOfNextYearInFormat(timeObject, format) string {{ dtLastDayOfNextYearInFormat timeObject “2006/02/01” }}
dtFuture Datetime manipulation to get the nearest future quarter, half or full hour dtFuture(timeObject, type) timeObject {{ dtFuture timeObject “quarter” }}
dtStartOfDay Datetime manipulation to get the start of the day time object dtStartOfDay(timeObject) timeObject {{ dtStartOfDay timeObject }}
dtEndOfDay Datetime manipulation to get the end of the day time object dtEndOfDay(timeObject) timeObject {{ dtEndOfDay timeObject }}
dtNearestWeekday returns nearest weekday of the timeObject. true - calculates in feature, otherwise in past dtNearestWeekday(timeObject, weekday, false?) timeObject {{ dtNearestWeekday timeObject, “Monday” }}
convertTimeUnits Converts one time unit to another. Possible units: s, m, h. convertTimeUnits(amount, from, to) float64 {{ convertTimeUnits 5 “h” “s” }}
parseDuration Parses text formated duration to the time.Duration. Accepts value in format 2h45m35.5s parseDuration(dur) time.Duration {{ parseDuration “245m” }}
parseDuration (roundTo) parseDuration could also receive optional roundTo setting, which is one of s, m and h parseDuration(dur, roundTo) time.Duration {{ parseDuration “245m35s” “m” }}
formatDuration Formats passed duration to the specified format. Receives time.Duration or string types (check syntax) formatDuration(dur, format) string {{ formatDuration “245m” “05 sec” }}
formatDuration (roundTo) formatDuration with optional roundTo param, which is one of s, m and h formatDuration(dur, format, roundTo) string {{ formatDuration “245m22.6s” “05 sec” “s” }}
Unix Its possible to get the unix value on any timeObject timeObject.Unix {{ $timeObject.Unix }}

Example when using current accounts time zone.

{{ $timeZone :=.Data.ErplyApi.ConfigurationList.timezone | toString }}
{{ unixToDate 1683201802 $timeZone "2006-02-01" }}
{{ formatDate "2022-11-11" "2006-01-02" "2006/02/01" $timeZone }}

Examples using dt functions:

<!-- DT date manipulations -->

<p>
  <!-- Get a usable date object -->
  <!-- Basic current server time (UTC) -->
  Server current time: {{ dtCurrent }}
</p>
<p>
  <!-- Get current time in a specific timezone / location -->
  <!-- #1 parameter here is the tz timezone identifier value
  get a reference for the available values in https://en.wikipedia.org/wiki/List_of_tz_database_time_zones -->
  Current time in TZ: {{ dtCurrentIn "America/New_York" }}
</p>
<p>
  <!-- Use an input in any possible format from another source -->
  <!-- #1 parameter is the input date value -->
  <!-- #2 parameter is the input date format, use the https://gosamples.dev/date-time-format-cheatsheet/ to read on how the format works  -->
  <!-- #3 is the timezone tz identifier value of the output time -->
  Time conversion from another format: {{ dtFromDateTime "2023-06-06T15:12:12Z07:00"
  "2006-01-02T15:04:05Z07:00" "America/New_York" }}
</p>

<!-- Manipulate the value of the date-->

<!-- Reduce by 1 year -->
<p>Current time reduced by 1 year: {{ dtAdjustDate dtCurrent "year" -1 }}</p>
<!-- Add 1 year -->
<p>Current time plus 1 year: {{ dtAdjustDate dtCurrent "year" 1 }}</p>
<!-- Reduce by 1 month -->
<p>Current time reduced by 1 month: {{ dtAdjustDate dtCurrent "month" -1 }}</p>
<!-- Add 1 month -->
<p>Current time plus 1 month: {{ dtAdjustDate dtCurrent "month" 1 }}</p>
<!-- Reduce by 1 day -->
<p>Current time reduced by 1 day: {{ dtAdjustDate dtCurrent "day" -1 }}</p>
<!-- Add 1 day -->
<p>Current time plus 1 day: {{ dtAdjustDate dtCurrent "day" 1 }}</p>

<!-- Get specific values -->
<!-- First and last day of week -->
<!-- #1 value of one of the provided functions: dtCurrent, dtCurrentIn or dtFromDateTime -->
<!-- #2 boolean value - true if week start day is Sunday and false for Monday -->
<p>First day of week(monday): {{ dtFirstDayOfWeek dtCurrent false }}</p>
<p>First day of week(sunday): {{ dtFirstDayOfWeek dtCurrent true }}</p>
<p>Last day of week(monday): {{ dtLastDayOfWeek dtCurrent false }}</p>
<p>Last day of week(sunday): {{ dtLastDayOfWeek dtCurrent true }}</p>

<!-- First and last day of the month -->
<!-- #1 value of one of the provided functions: dtCurrent, dtCurrentIn or dtFromDateTime -->
<p>First day of month: {{ dtFirstDayOfMonth dtCurrent }}</p>
<p>Last day of month: {{ dtLastDayOfMonth dtCurrent }}</p>

<p>First day of next month: {{ dtAdjustDate dtCurrent "month" 1 | dtFirstDayOfMonth }}</p>
<p>First day of previous month: {{ dtAdjustDate dtCurrent "month" -1 | dtFirstDayOfMonth }}</p>

<!-- First and last day of the year -->
<!-- #1 value of one of the provided functions: dtCurrent, dtCurrentIn or dtFromDateTime -->
<p>First day of the year: {{ dtFirstDayOfYear dtCurrent }}</p>
<p>Last day of the year: {{ dtLastDayOfYear dtCurrent }}</p>

<p>First day of next year: {{ dtAdjustDate dtCurrent "year" 1 | dtFirstDayOfYear }}</p>
<p>Last day of previous year: {{ dtAdjustDate dtCurrent "year" -1 | dtLastDayOfYear }}</p>

<!-- Format the date to anything thats needed -->
<!-- #1 dt time object from any of the previous functions -->
<!-- #2 in what format you need to get the date time format, use https://gosamples.dev/date-time-format-cheatsheet/ as date format reference -->
<p>Date to UnixDate: {{ dtToFormat dtCurrent "Mon Jan _2 15:04:05 MST 2006" }}</p>
<p>Date to RFC822: {{ dtToFormat dtCurrent "02 Jan 06 15:04 MST" }}</p>
<p>Date to RFC1123: {{ dtToFormat dtCurrent "Mon, 02 Jan 2006 15:04:05 MST" }}</p>
<p>Date to RFC3339Nano: {{ dtToFormat dtCurrent "2006-01-02T15:04:05.999999999Z07:00" }}</p>
<p>Date only: {{ dtToFormat dtCurrent "2006-01-02" }}</p>
<p>Time only: {{ dtToFormat dtCurrent "15:04:05" }}</p>
<p>Custom: {{ dtToFormat dtCurrent "15:04:05 2006/01/02" }}</p>

<!-- Chain current time -> add 1 year -> first day of the year -> to my format -->
<!-- With a set variable -->
<p>
  {{ $firstDayOfNextYear := dtAdjustDate dtCurrent "year" 1 | dtFirstDayOfYear }}
  First day of next year in RFC822: {{ dtToFormat $firstDayOfNextYear "02 Jan 06 15:04 MST" }}
</p>

<!-- Use helper functions -->
<!-- #1 date data from the previous functions -->
<!-- #2 what format to concert to -->
<!-- Note that these functions alter the date by 1 depending if its first or last, so further adjustments to the input date here is not needed -->
<p>First day of pervious month: {{ dtFirstDayOfPreviousMonthInFormat dtCurrent "02 Jan 06 15:04 MST"
  }}</p>
<p>First day of current month:{{ dtFirstDayOfCurrentMonthInFormat dtCurrent "02 Jan 06 15:04 MST"
  }}</p>
<p>First day of next month:{{ dtFirstDayOfNextMonthInFormat dtCurrent "02 Jan 06 15:04 MST" }}</p>
<p>First day of pervious year:{{ dtFirstDayOfPreviousYearInFormat dtCurrent "02 Jan 06 15:04 MST"
  }}</p>
<p>First day of current year:{{ dtFirstDayOfCurrentYearInFormat dtCurrent "02 Jan 06 15:04 MST"
  }}</p>
<p>First day of next year:{{ dtFirstDayOfNextYearInFormat dtCurrent "02 Jan 06 15:04 MST" }}</p>

<p>Last day of pervious month: {{ dtLastDayOfPreviousMonthInFormat dtCurrent "02 Jan 06 15:04 MST"
  }}</p>
<p>Last day of current month:{{ dtLastDayOfCurrentMonthInFormat dtCurrent "02 Jan 06 15:04 MST"
  }}</p>
<p>Last day of next month:{{ dtLastDayOfNextMonthInFormat dtCurrent "02 Jan 06 15:04 MST" }}</p>
<p>Last day of pervious year:{{ dtLastDayOfPreviousYearInFormat dtCurrent "02 Jan 06 15:04 MST"
  }}</p>
<p>Last day of current year:{{ dtLastDayOfCurrentYearInFormat dtCurrent "02 Jan 06 15:04 MST" }}</p>
<p>Last day of next year:{{ dtLastDayOfNextYearInFormat dtCurrent "02 Jan 06 15:04 MST" }}</p>

<!-- DT future -->
<h2>Get nearest future quarter, half or full hour for a datetime</h2>

{{ $sample := dtFromDateTime "12:14" "03:04" "GMT" }}

<p>{{ dtToFormat (dtFuture $sample "quarter") "03:04" }}</p>
<p>{{ dtToFormat (dtFuture $sample "half") "03:04" }}</p>
<p>{{ dtToFormat (dtFuture $sample "full") "03:04" }}</p>

<!-- Start and end of the day -->
<p>{{ $timeZone := toString .Data.ErplyApi.ConfigurationList.timezone }}</p>
<p>{{ $today := dtCurrentIn $timeZone }}</p>

{{ $start := dtStartOfDay $today }}
{{ $end := dtEndOfDay $today }}

<!-- Get Unix on any timeObject (from any of the helpers that return that type) value -->
<p>{{ $start.Unix }}</p>
<p>{{ $end.Unix }}</p>

Types conversions

Name Description Usage Samples
toString Attempts to convert a type to a string. Can be used to make sure an interface value is always a string. toString(undefined) {{ toString 100 }}
toFloat Converts a string to float. Created value can be used on other helper functions. toFloat(string) {{ toFloat “100.23” }}
toInt Converts a string to int. Created value can be used on other helper functions. toInt(string) {{ toInt “100” }}
toBool Converts a string, int or float to boolean. Created value can be used on other helper functions. toBool(string) {{ toBool “1” }}
intToString Converts a int to string. Created value can be used on other helper functions. intToString(int) {{ intToString 100 }}
stringToUint32 Converts a string to int32. Created value can be used on other helper functions. stringToUint32(string) {{ stringToUint32 “100” }}
intToUint32 Converts a int to int32. Created value can be used on other helper functions. intToUint32(int) {{ intToUint32 100 }}
floatToString Converts a float to string. Created value can be used on other helper functions. floatToString(float64) {{ floatToString 100.5 }}
floatToInt Converts a float to int. Created value can be used on other helper functions. floatToString(float64) {{ floatToInt 100.5 }}
floatToEvenString Converts a float to int and returns as a string. Created value can be used on other helper functions. floatToEvenString(float64) {{ floatToEvenString 100.5 }}

Example where the data could be stored in a JS variable for js usage.

<script>
  (function () {
    const myData = JSON.parse({{ .Data.WMSApi.ProductPickingEfficiencyList | toJson}})
  })();
</script>

Constructors

Name Description Usage Samples
mkMap Creates map[string]string, parameters count must be even (2,4,etc), first is key and second - value mkMap(key, value, …) {{ m := mkMap “key” “val” “key2” “val2” }}
mkAnyMap Creates map[string]any, parameters count must be even (2,4,etc), first is key and second - value mkAnyMap(key, value, …) {{ m := mkAnyMap “key” “val” “key2” 2 }}
mkCtxMap Creates map[string]any, first parameter should be the template context (the dot), rest is the same as mkAnyMap. This function makes sure that everything in the context is still available when used as a passing function to pass data to partials. mkCtxMap(., key, value, …) {{ m := mkCtxMap . “key” “val” “key2” 2 }}
mkIntArray Creates array (slice) of integers, if parameters not passed then empty array created mkIntArray(1, 2, …) {{ m := mkIntArray 1 2 }}
mkStringArray Creates array (slice) of strings, if parameters not passed then empty array created mkStringArray(“foo”, “bar”, …) {{ m := mkStringArray “foo” “bar” }}

Math

Name Description Function arguments Usage
round Rounds the float value to nearest integer value round(myFloat) {{ round 100.456 }}
roundToEven Rounds to the nearest integer, rounding ties to even. roundToEven(myFloat) {{ round 100.456 }}
roundTo Round to the nearest requested precision point. roundTo(myFloat, decimalPlaces) {{ roundTo 100.456 2 }}
roundToAndHumanize Round to the nearest requested precision point and add comma separation for thousands. roundToAndHumanize(myFloat, decimalPlaces) {{ roundToAndHumanize 100.456 2 }}
roundToAndHumanizeWithConf Same as roundToAndHumanize, but with configuration options. E.g " ," will apply space to thousand separator and comma to decimal separator roundToAndHumanizeWithConf(myFloat, decimalPlaces, " ,") {{ roundToAndHumanizeWithConf 100.456 2 “,.” }}
roundAndFormatTo1f Round to 1 decimal. roundAndFormatTo1f(myFloat) {{ roundAndFormatTo1f 100.456 }}
roundAndFormatTo2f Round to 2 decimals. roundAndFormatTo2f(myFloat) {{ roundAndFormatTo2f 100.456 }}
roundAndFormatTo3f Round to 3 decimals. roundAndFormatTo3f(myFloat) {{ roundAndFormatTo3f 100.456 }}
roundAndFormatTo4f Round to 4 decimals. roundAndFormatTo4f(myFloat) {{ roundAndFormatTo4f 100.456 }}
toAbsolute Convert to absolute value toAbsolute(myValue) {{ toAbsolute -100.456 }}
removeZeroes Remove trailing zeroes. Example: 100.0500 -> 100.05 removeZeroes(myFloat) {{ removeZeroes -100.4560000 }}
humanize Add comma separation to thousands. Example: 100000.00 -> 100,000.00 humanize(myFloat) {{ humanize 100.456 }}
humanizeWithConf Same as humanize, but with configuration options. E.g " ," will apply space to thousand separator and comma to decimal separator humanizeWithConf(myFloat, decimals, " ,") {{ humanizeWithConf 100.456 2 “,.” }}
add Adds all the input parameters. Can define as many parameters as needed. add(myVal1, myVal2, …) {{ add -100.4560000 1 3 }}
subtract Subtracts the input parameters from 0. Can define as many parameters as needed. subtract(myVal1, myVal2, …) {{ subtract -100.4560000 1 3 }}
subtractFrom Subtracts the input parameters from specified value. Can define as many parameters as needed. subtractFrom(initialVal, myVal1, myVal2, …) {{ subtractFrom -100.4560000 1 3 }}
divide Divides the input parameters. Can define as many parameters as needed. Note that 0 values are ignored. divide(myVal1, myVal2, …) {{ divide -50 1 }}
div Divide 2 exact values div(val1, val2) {{ div -50 1 }}
multiply Multiplies the input parameters. Can define as many parameters as needed. Note that 0 values are ignored. multiply(myVal1, myVal2, …) {{ multiply -50 1 }}
mul Multiply 2 exact values mul(val1, val2) {{ mul -50 1 }}
pow Calculates the parameter 1 to the power parameter 2 power pow(myFloat1, myFloat2) {{ pow 1.1 1.2 }}
ceil Round a floating point value up to the nearest integer ceil(myFloat1) {{ ceil 1.1 }}
floor Returns the greatest integer value less than or equal to x. floor(myFloat1) {{ floor 1.1 }}

As with the functions that accept a single parameter the same applies here that they can also be used with the pipe notation.

{{ 13.6 | roundToEven }}
{{ 13.6 | round }}

Maps, lists and arrays

Name Description Definition Samples
getListTotal Gets the total value of the requested list column. getListTotal(myListData, myColumn) {{ getListTotal .Data.WMSApi.InboundEfficiencyList “Duration” }}
getRoundedListTotal Gets the total value of the requested list column. Applies the roundTo logic. getRoundedListTotal(myListData, myColumn, decimals) {{ getRoundedListTotal .Data.WMSApi.InboundEfficiencyList “Duration” 2 }}
getRoundedAndHumanizedListTotal Same as getRoundedListTotal. Applies humanization (thousands comma separation) logic. getRoundedAndHumanizedListTotal(myListData, myColumn, decimals) {{ getRoundedAndHumanizedListTotal .Data.WMSApi.InboundEfficiencyList “Duration” 2 }}
isInStringArray Checks if element exists in array of strings. isInStringArray(“el”, strSlice) {{ isInStringArray “el” someStringArray }}
isInIntArray Checks if element exists in array of ints. isInIntArray(2, intSlice) {{ isInIntArray 2 someIntArray }}
indexOfStringSlice Returns index of element from array of strings. 0 if not found. indexOfStringSlice(strSlice, “el”) {{ indexOfStringSlice someStringArray “el” }}
addToIntArray Appends element to the array of integers ([]int). addToIntArray(intSlice, 2) {{ addToIntArray intArray 2 }}
addToStringArray Appends element to the array of strings ([]string). addToStringArray(strSlice, “el”) {{ addToStringArray stringArray “el” }}
arrayToString Converts an array of any type to string having elements separated by delim. string array elements must not contain spaces arrayToString(strSlice, “,”) string {{ arrayToString array “,” }}
setAnyMapValue Sets key-value entry to the map, supports any type. setAnyMapValue(map[string]any, string, any) map[string]any {{ setAnyMapValue $map “foo” 2 }}
in Helper function imitating regular loop for i:=0; i<10; i++. in(from, to) {{ range $i := in 0 10 }}

{{ $i }}

{{ end }}
inZeroPadding Same as in, but fills zeroes for smaller number sizes. E.g. in(from, to) {{ range $i := in 0 10 }}

{{ $i }}

{{ end }}
reverseStringArray Reverses string array ["one", "two"] => ["two", "one"] reverseStringArray(myStringArray) {{ reverseStringArray $array }}
getYearRange Similar to in but the last adjustment is done against the current year getYearRange(from, adjustCurrentYear) {{ range getYearRange 1800 10 }}
inZeroPadding Generate a range of string with given amount of zero paddings inZeroPadding(from, to, numberOfPads) {{ range inZeroPadding 1 12 2 }}
getSortedKeys Extracts keys from map then sorts them based on the passed order (asc or desc) and returns keys as []string getSortedKeys(map[string]any, “desc”) {{ getSortedKeys $m “desc” }}

Get totals notes

Note that getListTotal, getRoundedListTotal and getRoundedAndHumanizedListTotal functions under the hood return the value as a string. This means if you intend to use the value for further processing using the same helper functions then you would need to convert the values to required types first.

Example:

{{ $val := getRoundedListTotal .Data.WMSApi.InboundEfficiencyList "OpenTimestamp" 2 }}
{{ $val := toFloat $val }}
{{ $val := add $val 15.15756 }}
{{ roundToAndHumanize $val 2 }}

Also note that the function can be used with any integrated lists that contain an integer or float column.

Working with JSON

This section contains all json related manipulations, using helper functions, gjson.Result object and modifier functions that could be used during path assembly for the gjson.Result.Get calls.

Name Description Usage Samples
toJson Attempts to convert a type to valid json. Can be used on list types or entities to get the structure for JS usage. toJson(data) string {{ toJson .Data.WMSApi.InboundEfficiencyList }}
jsonLookup Looking json value by path. May return different types: int, string, etc… If under path json, returns map[string]any jsonLookup(jsonStr, path) any {{ jsonLookup `{“foo”:“bar”}` “foo” }}
jsonLookupRaw Looking json value by path. Always returns string. If under path json, returns stringified json JsonLookupRaw(jsonStr, path) string {{ JsonLookupRaw `{“foo”:“bar”}` “foo” }}
jsonType Returns json type for path. Values: “Null”, “Number”, “String”, “JSON”, “Boolean” JsonType(jsonStr, path) string {{ JsonType `{“foo”:“bar”}` “foo” }}
jsonArrayLen Returns length of json array. Returns 0 if under path is object JsonArrayLen(jsonStr, path) int {{ JsonArrayLen [] "" }}
jsonObjKeys Returns all keys of json object under path. JsonObjKeys(jsonStr, path) int {{ JsonObjKeys `{“foo”:1,“bar”:2}` "" }}
jsonResult Returns JSON Result object containing many valuable functions and fields. More details jsonResult(jsonStr, path) gjson.Result {{ jsonResult `{“foo”:1,“bar”:2}` “foo” }}
jsonPretty Takes in string or []byte and returns beautified json jsonPretty(json) string {{ jsonPretty `{“foo”:1,“bar”:2}` }}
jsonSet Adds values to and existing json structure jsonSet(json, path, val) string {{ jsonSet `{“foo”: 1}` “bar” 2 }}
jsonSetObj Adds an entire raw json string object or array structure jsonSetObj(json, path, val) string {{ jsonSetObj `{“foo”: 1}` “bar” `{“someObject”: “test”}` }}
jsonDel Remove values from an existing json structure jsonDel(json, path) string {{ jsonDel `{“foo”: 1, “bar: 2}` “bar” }}

Samples

JSON sample payload

{
  "name": {
    "first": "Tom",
    "last": "Anderson"
  },
  "age": 37,
  "children": [
    "Sara",
    "Alex",
    "Jack"
  ],
  "emptyArray": [],
  "fav.movie": "Deer Hunter",
  "friends": [
    {
      "first": "Dale",
      "last": "Murphy",
      "age": 44,
      "nets": [
        "ig",
        "fb",
        "tw"
      ]
    },
    {
      "first": "Roger",
      "last": "Craig",
      "age": 68,
      "nets": [
        "fb",
        "tw"
      ]
    },
    {
      "first": "Jane",
      "last": "Murphy",
      "age": 47,
      "nets": [
        "ig",
        "tw"
      ]
    }
  ]
}

Function usage examples

<div>
  {{ $jsonStr := `{"name": {"first": "Tom", "last": "Anderson"},"age":37,"children":
  ["Sara","Alex","Jack"],"emptyArray": [],"fav.movie": "Deer Hunter","friends": [{"first": "Dale",
  "last": "Murphy", "age": 44, "nets": ["ig", "fb", "tw"]},{"first": "Roger", "last": "Craig",
  "age": 68, "nets": ["fb", "tw"]},{"first": "Jane", "last": "Murphy", "age": 47, "nets": ["ig",
  "tw"]}]}`}}

  <p>Get value: <code>{{ jsonLookup $jsonStr "age" }}</code></p>
  <!-- 37 -->
  <p>Get value map: <code>{{ jsonLookup $jsonStr "name" }}</code></p>
  <!-- map[first:Tom last:Anderson] -->
  <p>Get value as string: <code>{{ jsonLookupRaw $jsonStr "age" }}</code></p> <
  !-- "37" -->
  <p>Get obj as string: <code>{{ jsonLookupRaw $jsonStr "name" }}</code></p>
  <!-- {"first": "Tom", "last": "Anderson"} -->
  <p>Get json type: <code>{{ jsonType $jsonStr "name" }}</code></p>
  <!-- JSON -->
  <p>Get json type: <code>{{ jsonType $jsonStr "age" }}</code></p>
  <!-- Number -->
  <p>Get json type: <code>{{ jsonType $jsonStr "children" }}</code></p>
  <!-- JSON -->
  <p>Get json type: <code>{{ jsonType $jsonStr "not_exist" }}</code></p>
  <!-- NULL -->
  <p>Json array length: <code>{{ jsonArrayLen $jsonStr "children" }}</code></p>
  <!-- 3 -->
  <p>Json array length (object): <code>{{ jsonArrayLen $jsonStr "name" }}</code></p>
  <!-- 0 -->
  <p>Json object keys root: <code>{{ jsonObjKeys $jsonStr "" }}</code></p>
  <!-- [name age children emptyArray fav.movie friends] -->
  <p>Json object keys path: <code>{{ jsonObjKeys $jsonStr "name" }}</code></p>
  <!-- [first last] -->
  <p>Json object keys array: <code>{{ jsonObjKeys $jsonStr "children" }}</code></p>
  <!-- [] -->
</div>

JSON Result object

GOERP uses library gjson to read and process json at specific path. Helper function jsonResult returns gjson.Result object which contains many valuable fields and functions for json processing. All those functions and fields may significantly simplify work with json inside templates.

Fields

Name Description Usage Samples
Type Type is the json type (Null, False, Number, String, True, JSON) Result.Type.String() string {{ $result.Type }}
Raw Raw is the raw json Result.Raw string {{ $result.Raw }}
Index Index of raw value in original json, zero means index unknown Result.Index int {{ $result.Index }}
Indexes Indexes of all the elements that match on a path containing the ‘#’ query character Result.Indexes []int {{ $result.Indexes }}

Functions

Name Description Usage Samples
String Returns a string representation of the value String() string {{ $result.String }}
Bool Returns a boolean representation of the value Bool() bool {{ $result.Bool }}
Int Returns a integer representation of the value Int() int64 {{ $result.Int }}
Uint Returns a unsigned integer representation of the value Uint() uint64 {{ $result.Uint }}
Float Returns a float representation of the value Float() float64 {{ $result.Float }}
Time Returns a time.Time representation of the value Time() time.Time {{ $result.Time }}
Array Returns array of elements as Result objects Array() []Result {{ $result.Array }}
IsObject Returns true if result is a json object IsObject() bool {{ if $result.IsObject }} {{ end }}
IsArray Returns true if result is a json array IsArray() bool {{ if $result.IsArray }} {{ end }}
IsBool Returns true if result is a json boolean IsBool() bool {{ if $result.IsBool }} {{ end }}
Map Returns map of nested results. root Result must be json object Map() map[string]Result {{ $result.Map }}
Get Searches result for the specified path. Check queries guide for more info and playground Get(path string) Result {{ $result.Get $path }}
Exists Returns true if json value exists Exists() bool {{ if $result.Exists }} {{ end }}
Value Returns value of related type (int, string, etc.), map[string]any for json objects and []any for arrays Value() any {{ $result.Value }}
Less Return true if a token is less than another token. Null < False < Number < String < True < JSON Less(token Result, caseSensitive bool) bool {{ $result.Less $result2 false }}
Paths Paths returns the original GJSON paths for a Result Paths(json string) []string {{ $result.Paths $entireJson }}
Path Paths returns the original GJSON path for a Result Path(json string) string {{ $result.Path $entireJson }}

Modifier functions

Modifier functions could be used in paths for the gjson.Result. They always starts with @ (e.g. @sum) and may be chained using | (e.g. @sum:2|@suffix:$). Also, some modifiers may accept additional arguments that could be passed through : (e.g. @sum:2, which is decimals in this case).

Please note, $r in samples is the gjson.Result object.

Name Description Usage Samples
commaSepStr Converts json array to comma separated string. null, {}s and []s would be skipped. When working with numbers, pass int as an argument “@commaSepStr:arg” {{ $r.Get “@commaSepStr” }}
arrToStr Same as commaSepStr, but allows to define the separator through arguments. “@arrToStr:arg” {{ $r.Get “@arrToStr:;” }}
prefix Sets prefix to the value “@prefix:arg” {{ $r.Get “@prefix:pref_” }}
suffix Sets suffix to the value “@suffix:arg” {{ $r.Get “@suffix:_suf” }}
getOldestItemDate Automation specific. Returns oldest record from items array. Arg: format of output date “@getOldestItemDate:arg” {{ $r.Get “@getOldestItemDate:2006-01-02T15:04:05Z” }}
unique Removes duplicates from the json array “@unique” {{ $r.Get “@unique” }}
notNull Removes nulls from the json array “@notNull” {{ $r.Get “@notNull” }}
sum Sums up all values in json array, stringified numbers are valid, other types are ignored. Define decimals through arg “@sum:arg” {{ $r.Get “@sum:2” }}
min Extracts minimum number from json array, same restrictions as in sum “@min” {{ $r.Get “@min” }}
max Extracts maximum number from json array, same restrictions as in sum “@max” {{ $r.Get “@max” }}
skip Allows to skip value (returns empty value) based on conditions: Supported args: zero, eq,val, neq,val, gt,val, lt,val “@skip:arg” {{ $r.Get “@skip:eq,John” }}

Working with XML

All XML content converted to the JSON when received from the API. And JSON body is converted to the XML when sending to the API. This allows to work with XML content in the same way as with JSON, using all available options like chaining, generating, etc. Although, because XML cannot be fully represented as a JSON structure, some additional rules must be followed while assembling or reading XML payload through JSON.

Special rules

Use the following rules whenever reading the XML Response or constructing the XML request.

  • All tag attributes are represented as a separate key with the - prefix. For example, the XML <tag attr="value"></tag> would be represented as {"tag": {"-attr": "value"}}.
  • If tag contains attributes and text, the text would be represented as a separate field with the #text name. For example, the XML <tag attr="value">content</tag> would be represented as {"tag": {"-attr": "value", "#text": "content"}}.
  • <?xml version="1.0" encoding="UTF-8"?> header added automatically to the XML request before sending it to the API.

XML payload in JSON sample

XML

<?xml version="1.0" encoding="UTF-8"?>
<bookstore xmlns:p="urn:schemas-books-com:prices">

  <book category="COOKING">
    <title lang="en">Everyday Italian</title>
    <author>Giada De Laurentiis</author>
    <year>2005</year>
    <p:price>30.00</p:price>
  </book>

  <book category="CHILDREN">
    <title lang="en">Harry Potter</title>
    <author>J K. Rowling</author>
    <year>2005</year>
    <p:price>29.99</p:price>
  </book>

  <book category="WEB">
    <title lang="en">XQuery Kick Start</title>
    <author>James McGovern</author>
    <author>Per Bothner</author>
    <author>Kurt Cagle</author>
    <author>James Linn</author>
    <author>Vaidyanathan Nagarajan</author>
    <year>2003</year>
    <p:price>49.99</p:price>
  </book>

  <book category="WEB">
    <title lang="en">Learning XML</title>
    <author>Erik T. Ray</author>
    <year>2003</year>
    <p:price>39.95</p:price>
  </book>

</bookstore>

JSON generated from XML

{
  "bookstore": {
    "-xmlns:p": "urn:schemas-books-com:prices",
    "book": [
      {
        "-category": "COOKING",
        "author": "Giada De Laurentiis",
        "p:price": "30.00",
        "title": {
          "#text": "Everyday Italian",
          "-lang": "en"
        },
        "year": "2005"
      },
      {
        "-category": "CHILDREN",
        "author": "J K. Rowling",
        "p:price": "29.99",
        "title": {
          "#text": "Harry Potter",
          "-lang": "en"
        },
        "year": "2005"
      },
      {
        "-category": "WEB",
        "author": [
          "James McGovern",
          "Per Bothner",
          "Kurt Cagle",
          "James Linn",
          "Vaidyanathan Nagarajan"
        ],
        "p:price": "49.99",
        "title": {
          "#text": "XQuery Kick Start",
          "-lang": "en"
        },
        "year": "2003"
      },
      {
        "-category": "WEB",
        "author": "Erik T. Ray",
        "p:price": "39.95",
        "title": {
          "#text": "Learning XML",
          "-lang": "en"
        },
        "year": "2003"
      }
    ]
  }
}

Simple example of sending and reading XML

{{ template "pg-layout" . }}

{{ define "body" }}

<form method="post">
    <input type="hidden" name="CustomApi.Api.Post.soapReq" value="https://www.dataaccess.com/webservicesserver/NumberConversion.wso">
    <input type="hidden" name="CustomApi.Api.Header.soapReq.Content-Type" value="text/xml; charset=utf-8">
    <input type="hidden" name="CustomApi.Api.Xml.soapReq.string.soap:Envelope.-xmlns:soap" value="http://schemas.xmlsoap.org/soap/envelope/">
    <input type="hidden" name="CustomApi.Api.Xml.soapReq.stirng.soap:Envelope.soap:Body.NumberToWords.-xmlns" value="http://www.dataaccess.com/webservicesserver/">
    <input type="text" name="CustomApi.Api.Xml.soapReq.string.soap:Envelope.soap:Body.NumberToWords.ubiNum" value="">

    <button type="submit">Send</button>
</form>

<h2>Sent:</h2>
<pre>{{ .Data.CustomApi.Api.Requests.soapReq.Xml.Raw | jsonPretty }}</pre>

<h2>Received:</h2>
<pre>{{ .Data.CustomApi.Api.Requests.soapReq.Response.Raw | jsonPretty }}</pre>

Requested number as text: 
<b>{{ .Data.CustomApi.Api.Requests.soapReq.Response.Get "Envelope.soap:Body.NumberToWordsResponse.m:NumberToWordsResult" }}</b>

{{ end }}

If conditions

If conditions can be used in the templates

{{ if true }}
    <h1>True</h1>
{{ else }}
    <h1>False</h1>
{{ end }}

Note that if condition comparison is type sensitive. If invalid types (types that cannot be compared) are given the template parser will break from that point onward.

Should always make sure that the types are comparable or cast them to comparable types before the condition check

<!-- Invalid, template will break from this point  -->
{{ if gt 1 "2" }}
{{ end }}

<!-- Valid -->
{{ if g1 1 (toInt "2") }}
{{ end }}

<!-- Valid, type cast from dynamic api result -->
{{ if g1 1 ($dynRes.Get "id").Int }}
{{ end }}

Available operators

  • eq - boolean truth of arg1 == arg2
  • ne - boolean truth of arg1 != arg2
  • lt - boolean truth of arg1 < arg2
  • le - boolean truth of arg1 <= arg2
  • gt - boolean truth of arg1 > arg2
  • ge - boolean truth of arg1 >= arg2
  • md - boolean truth of arg1 % arg2

Multiple conditions

Multiple conditions can also be described. There can be any number of conditions.

{{ if and (condition1) (condition2) }}
{{ end }}

For this we can use and & or. Use “()” to separate arguments.

{{ if and (or (condition1) (condition2)) (condition2) }}
{{ end }}

Sample

{{ if and (eq 1 2) (ne 3 4) }}
{{ end }}

Tools helper

Tools is a special helper that has more powerful access to the contents of the context.

Generates a full path link to the source static file. Makes sure the link does not break if the site is moved to a custom domain or if the link contains custom path parameters.

This is a replacement for the older staticFileLink and takes care multiple shortcoming of the original.

<script src="{{ .Tools.StaticLink " AAA-Etag-js" }}"></script>

IsPath

Checks if we are currently navigating to the given custom path. Counts all 3 custom paths.

{{ .Tools.IsPath "path1" }}
{{ .Tools.IsPath "path1/path2" }}
{{ .Tools.IsPath "path1/path2/path3" }}

Also used for automatic sourcemap importing for sub-paths.

GetNavPath

Generates a valid link path that composes of the current client code, language and the given path value. Counts in possible navigation differences from custom domains.

{{ .Tools.GetNavPath "da-csv-export" }}

This would generate the correct paths according to the current handler

/104146/en/da-csv-export <!-- regular -->
/public/104146/en/da-csv-export?some=value <!-- public -->
/en/da-csv-export?some=value <!-- custom domain -->
Info

Optional language parameter is available from version 1.215+

{{ .Tools.GetNavPath "da-csv-export" "fi" }}
/104146/fi/da-csv-export <!-- regular -->
/public/104146/fi/da-csv-export?some=value <!-- public -->
/fi/da-csv-export?some=value <!-- custom domain -->

Browser

A separate function to parse data from the user agent header to specific data points. Can be used to determine if dealing with a mobile device or attempt to identify the platform used.

Browser

{{ .Browser.Name }}
{{ .Browser.Version }}
{{ .Browser.ShortVersion }}

Device

{{ .Browser.Device.IsConsole }}
{{ .Browser.Device.IsMobile }}
{{ .Browser.Device.IsTablet }}

Platform

{{ .Browser.Platform.Name }}
{{ .Browser.Platform.Version }}

Bot

{{ .Browser.Bot.Name }}
{{ .Browser.Bot.IsBot }}
{{ .Browser.Bot.Why }}

ExecTemplate

Executes template that can be assigned to the variable and could be passed to other data points (e.g. email body).

First of all we need to have template with payload:

{{ define "pg-in-build-template-parser-partial" }}

<h2>Hello, {{ .Session.ClientCode }}</h2>

<div align="left">
    Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut
    labore et dolore magna aliqua.
</div>
<div align="right">
    Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut
    labore et dolore magna aliqua.
</div>
<div align="center">
    Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut
    labore et dolore magna aliqua.
</div>
<div align="justify">
    Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut
    labore et dolore magna aliqua.
</div>

<h2>Using API calls inside executed template</h2>

{{ $warehouses := .Data.AccountAdminApi.Api.Requests.getWarehouses.Response.Get "data.warehouses" }}

<ul>
    {{ range $w := $warehouses.Array }}
    <li>{{ $w.Get "id" }}</li>
    {{ end }}
</ul>

{{ end }}

Then we can execute that template on other page and use resulting content wherever needed:

{{ template "pg-layout" . }}

{{ define "body" }}

<input type="hidden" name="AccountAdminApi.Api.Get.getWarehouses" value="v1/warehouse" data-preset-val="v1/warehouse" />
<input type="hidden" name="ErplyApi.Api.Post.getProducts" value="getProducts" data-preset-val="getProducts" />
<input type="hidden" name="ErplyApi.Api.PostParam.getProducts.getStockInfo" value="1" data-preset-val="1" />

<h1>Executing template from page...</h1>

<!-- We can pass any data to the function, like we do with regular partials inside templates -->
{{ $result := .Tools.ExecTemplate "pg-in-build-template-parser-partial" (.) }}
<pre>{{ $result }}</pre>

And result would be:

<h2>Hello, 104670</h2>

<div align="left">
    Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut
    labore et dolore magna aliqua.
</div>
<div align="right">
    Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut
    labore et dolore magna aliqua.
</div>
<div align="center">
    Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut
    labore et dolore magna aliqua.
</div>
<div align="justify">
    Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut
    labore et dolore magna aliqua.
</div>

<h2>Using API calls inside executed template</h2>

<ul>
    <li>1</li>
    <li>3</li>
    <li>4</li>
    <li>10</li>
    <li>11</li>
    <li>12</li>
    <li>13</li>
</ul>

CSP

Used to set server generated nonce to script and styles. When added correctly then the console error messages should no longer appear.

Link sources

<link rel="stylesheet" {{ .Tools.CSP }} href="{{ .Tools.StaticLink "Test-css" }}">
<script {{ .Tools.CSP }} src="{{ .Tools.StaticLink "add-billing-statement-js" }}"></script>

Inline

<head>
    <style {{ .Tools.CSP }}>
        .color-red {
            color: red;
        }
    </style>
</head>

<script {{ .Tools.CSP }}>
    console.log("test");
</script>

B2bAuthDomain (b2b specific)

While using Automat API to make b2b authentication calls, we need to pass the domain which is available only in the template (and we don’t have connection with template in the API calls). To get the current domain we can use .Tools.B2bAuthDomain and pass it to the necessary field.

<input type="hidden" name="AutomatApi.Api.Post.register" value="v1/b2b/register-user">
<input type="hidden" name="AutomatApi.Api.Json.register.string.domain" value="{{ .Tools.B2bAuthDomain }}">

IsModuleConfigured

Used to check if a specific module is currently enabled.

Note that this check is always checking against the current application (from the currently rendered template), so if the uuid being checked is a module for another app then it will always return false for it.

As a parameter give the uuid of the module you want to check. Returns a boolean value.

{{ .Tools.IsModuleConfigured "0cea1a50-5462-4fbc-9160-20ba082ec2be" }}

We can then generate some logic based on its result

{{ if .Tools.IsModuleConfigured "0cea1a50-5462-4fbc-9160-20ba082ec2be" }}
    <p>Extra features enabled</p>
{{ else }}
    <p>Extra features disabled</p>
{{ end }}

GetEnabledModules

Can be used to read a list of currently enabled modules.

Note that this check is always checking against the current application (from the currently rendered template), so it will not return uuid’s for modules that have been enabled for other applications.

Returns a comma separated list(string) of uuid’s.

{{ .Tools.GetEnabledModules }}
<!-- = uuid1,uuid2 -->

We can explode the result with split when we need to create a list out of it.

<ul>
{{ range (split .Tools.GetEnabledModules ",") }}
    <li>{{ . }}</li>
{{ end }}
</ul>

JWT

Basic helpers to sign and verify jwt tokens.

Create / Sign

Use the sign function to create a simple jwt token.

Parameters

  1. Claims / payload. Can be either an object, map or a json string.
{{ $myToken1 := sign `{"foo": "bar"}` "my-secret" "HS256" 1200 }}
{{ $myToken2 := sign (mkAnyMap "foo" "bar") "my-secret" "HS256" 1200 }}
  1. Secret to sign. Can be pulled from either variables or other sources that do not expose it to the html (api, cache).
<!-- Can be a string however the value is exposed in the html -->
{{ $claims := mkAnyMap "foo" "bar" }}
{{ $myToken := sign $claims "my-secret" "HS256" 1200 }}
<!-- Reading it from the api will hide the secret from parsed content -->
<input type="hidden" name="CaFaApi.Api.Get.myRequest1" value="v3/configuration" data-preset-val="v3/configuration">
<input type="hidden" name="CaFaApi.Api.Path.myRequest1.id" value="37" data-preset-val="37">
{{ $secret := (.Data.CaFaApi.Api.Requests.myRequest1.Response.Get "value").String }}

{{ $claims := mkAnyMap "foo" "bar" }}
{{ $myToken := sign $claims $secret "HS256" 1200 }}
<!-- Reading from variables is also hidden -->
{{ $claims := mkAnyMap "test" "val"}}
{{ $myToken := sign $claims (.Variables.Get "jwt.secret").String "HS256" 1200 }}
  1. Algorithm, currently allowed values are: HS256, HS512, RS256 and RS512.
  2. Expiration that will be added as the exp claim. Value is in seconds.

Verify

Verify the token against the key and expiration

When successful the result claims is a json result object where we can use the same Get fetching functionality as we use in dynamic api responses.

{{ $claims := verify $myToken "my-secret" }}
{{ $expVal := ($claims.Get "exp").Int }}

Error returned in content

If the validation fails the result will be returned as an error in the claims result.

{{ $claims := verify $myToken "my-secret" }}
<!-- {"error": "some failure reason"} -->

Fail template parse

If the validation fails then server returns the general error response screen.

{{ $claims := verifyWithFailure $myToken "my-secret" }}

Deleting table row(s)

  1. Import delete confirm modal {{ template "delete-confirm-modal" . }} to the page with the table. Modal can be custom, but there is few requirements that should be implemented: Modal confirm button must submit the form with method POST. Submitted form should include inputs:
    • <input type="hidden" name="postAction" value="delete">, value delete may be set dynamically using JS, or statically like in sample if related form used only for the delete action
    • <input type="hidden" name="postActionEntity" value="ModelDtoList">, where value is the entity name which table is related to. Usually ends with List
    • <input type="hidden" name="postActionIds" value="1,2,3">, where value is the ids of each entity separated by comma. If only one id, then just a number, like value=“1”
  2. There is also a default delete confirmation modal that can be used during custom template creation. Use the statement from option 1 to import it. Here is a sample how to use it:
<!-- GET may be used for search, after delete confirmation it would be changed to POST -->
<form method="get" id="entity-table-id">
  <input type="hidden" name="postAction" id="table-action">
  <input type="hidden" name="postActionIds" id="table-selected-rows" value="{{ .Data.Parameters.postActionIds }}">
  <input type="hidden" name="postActionEntity" id="table-entity">
  <button data-form-id="entity-table-id"
          data-delete-identifier="{{ .ID }}"
          data-post-action-entity="DeviceList"
          class="table-action-button action-row-red form-delete-button">
    <i class="material-icons material-icons-outlined">delete_forever</i>
  </button>
</form>
Note

Ids and names for inputs should be the same as in sample. Values are optional. The button should have data-form-id set to the related form id, data-post-action-entity should have the name of the entity that is related to the table. Button should have class form-delete-button.

Subsections of Language and translation

Languages usage

Current and default languages are stored inside globally available .Session object. There is 2 possible language codes available at the moment, - 2-letter and 3-letter code. 2-letter code is an iso code, but 3-letter is an internal erply api language code, please check erply api for more information.

  • {{ .Session.Language.Code }} - 2-letter iso code
  • {{ .Session.Language.LegacyCode }} - 3-letter erply legacy code
  • {{ .Session.DefaultLanguage.Code }} - 2-letter iso code of default language
  • {{ .Session.DefaultLanguage.LegacyCode }} - 3-letter erply legacy code of default language

Samples

Read translatable fields

Dynamic sample:

<input type="hidden" name="PIMApi.Api.Get.products" value="v1/product" data-preset-val="v1/product">
<ul>
    {{ range $row := .Data.PIMApi.Api.Requests.products.Response.Array }}
    <li>{{ $row.Get "id" }} | {{ $row.Get (printf "name.%s" $.Session.Language.Code) }}</li>
    {{ end }}
</ul>

Models sample:

{{ range $row := .Data.PIMApi.ProductList }}
  <!-- Print name value in the selected/default language -->
  <!-- Will print empty string if value under language code not found -->
  <p>Name in current lang: {{ index $row.Name $.Session.Language.Code }}</p>
  <p>Name in default lang: {{ index $row.Name $.Session.DefaultLanguage.Code }}</p>
{{ end }}

Display in tables

Dynamic sample:

{{ range $row := .Data.PIMApi.Api.Requests.products.Response.Array }}
<tr>
  <td>{{ $row.Get "id" }}</td>
  <td>{{ $row.Get (printf "name.%s" $.Session.Language.Code) }}</td>
</tr>
{{ end }}

Models sample:

<table>
  <thead>
  <tr>
    <th>Id</th>
    <th>Name</th>
  </tr>
  </thead>
  <tbody>
  
  {{ range $row := .Data.PIMApi.ProductList }}
  <tr>
    <td>{{ $row.ID }}</td>
    <td>{{ index $row.Name $.Session.Language.Code }}</td>
  </tr>
  {{ end }}
  
  </tbody>
</table>

Dynamic sample:

<select>
  {{ range $row := .Data.PIMApi.Api.Requests.products.Response.Array }}
  <!-- Print name value in the selected/default language  -->
  <option value='{{ $row.Get "id" }}'>{{ $row.Get (printf "name.%s" $.Session.Language.Code) }}</option>
  {{ end }}
</select>

Models sample:

<select>
  {{ range $row := .Data.PIMApi.ProductList }}
   <!-- Print name value in the selected/default language  -->
   <option value="{{$row.ID}}">{{ index $row.Name $.Session.Language.Code }}</option>
  {{ end }}
</select>

Write (create/update) translatable fields in multiple languages

Dynamic sample:

<form method="post">
  <input type="hidden" name="PIMApi.Api.Post.saveProd" value="v1/product">
  {{ range .Data.ErplyApi.LanguageList }}
  <div class="form-field">
    <label for="formInputName{{ .IsoCode }}">Name ({{ .IsoCode }}):</label>
    <input type="text" id="formInputName{{ .IsoCode }}"
           name='PIMApi.Api.Json.saveProd.string.name.{{ .IsoCode }}'
           value='{{ $.Data.PIMApi.Api.Requests.saveProd.Json.Get (printf "name.%s" .IsoCode) }}'>
  </div>
  {{ end }}

  <button type="submit">Send</button>
</form>

Models sample:

{{ range.Data.ErplyApi.LanguageList }}
<div class="form-field">
  <label for="formInputName{{ .IsoCode }}">Name ({{ .IsoCode }}):</label>
  <input type="text" id="formInputName{{ .IsoCode }}"
         name="AccountAdminApi.WarehouseInput.Name.{{ .IsoCode }}"
         value="{{ index $.Data.AccountAdminApi.WarehouseInput.Name .IsoCode }}">
</div>
{{ end }}

Translation feature

This feature can be used to define translations for the pages or applications that can be used to work automatically together with the language parameter from the url.

Translation file structure

The translation files are in json format and the language keys are expected to be in the iso 2 character codes.

{
  "en": {
    "key": "value"
  },
  "es": {
    "key": "value"
  }
}

Adding translations

Translations can be added on 2 different levels

  1. Application based translations
  2. Template based translations (page types only)

Application based translations

These are stored as separate files. New ones can be created from CREATE NEW -> Create new page and selecting the type translation.

These translations are only used by applications and the contents are shared between all the pages of the application once connected to one.

An application can also have multiple translation files. They are loaded in alphabetical order.

If 2 files contain the same key for the same language then the last loaded item will determine the used value.

Template based translations

These are stored on the templates (pages) and can only be used on the same template.

Translations can be added using the earth icon in the editor. Add template translation Add template translation

Template based translations will override application translations if both define the same key for a language.

Reading translations

Get in current language

This function uses the current navigation language code from url /en/ and used this to locate the language needed.

/000000/en/break-temp-6-page
{
  "en": {
    "key": "value"
  }
}
{{ .Translation.Get “key“ }}

This would be loaded as “value” when the page is opened.

Get in specified language

This function takes in a second parameter for the language code we want to get the result for, this one ignores the url path language.

If the input language cannot be found then it default to the path parameter instead.

/000000/en/break-temp-6-page
{
  "en": {
    "key": "value"
  }
}
{{ .Translation.GetIn “key“ “en“ }} 

This would be loaded as “value” when the page is opened.

External source for translations

We can also use an external source for the translations, as long as it follows the same json structure. Values entered with this will overload the translations from both application and the template if they define the same key.

{{  .Translation.GetWithSource “key“  {"en": {"key": "value_1"}} }}
{{  .Translation.GetWithSourceIn “key“  “en“ {"en": {"key": "value"}} }}

Template load order

Load order determines what value is returned if there are multiple sources for translations

  1. Application based translations in alphabetical order
  2. Template based translation
  3. Custom source (if defined)

When a key is not found

If a key is not found then the key itself is printed.

Optional default

You can override this behaviour (where the key is returned when translation is not found) by settings a special value in the translations file. Using this the methods ‘Get’ and ‘GetWithSource’ will attempt to return the translation in the set default language when the translation for the initial language code is not found.

{
    "default": {
        "to": "en"
    }
}

Translations optimization

There is also option to optimize huge translation files to one single file that will contain only translations that are actually used in the application. That feature would be useful for translation files larger than 1MB.

Configuring the optimization

To generate translation file and enable/disable it, just go to the application configuration, select templates section and click on the “Translations optimization” button.

Translations optimization Translations optimization

Then need to generate the file by clicking on the “Generate optimal translations” button. This action will generate a file in application with name {application UUID}-translation Please note: Optimization should be re-generated manually every time when at least one of the original translation files were modified. Check the Enable optimized translations option and confirm the action to basically enable the feature.

Note

Translation file generated in scope of main application and does not include translations from modules. If you have translations in modules, then they should contain only module specific translations, leaving huge translation files in main application. Translations from main application can be used inside modules.

When feature is enabled, GoErp will always use generated file instead of all application translation files.

Translations modal Translations modal

Handling CaFa JSON configurations

CAFA is storage for layered custom configurations, more info.

Goerp has an option to change or create json typed values in the CAFA entry. Using xPath, template creators can define specific location from the json and modify one field. All implementation options will create field if it is not existing in json. Also, defining new type for the field will update the type in json as well, so be careful.

The jsonLookup function accepts single json object and path as a parameters. Path supports standard xpath queries, more info.

CAFA key definition

"my-app::Company::level_1::sample::draft-sample-v1", where:

  • ::, - separator
  • my-app, - application parameter
  • Company, - level parameter
  • level_1, - level_id parameter
  • sample, - type parameter
  • draft-sample-v1, - name parameter

Single input sample

Single input (model JsonConfigurationSingleInput) form is always scoped around one configuration entry, meaning that when submitting the form, only one config entry gets processed and updated in the database. It is more performant in comparison with Bulk input saving.

This option should be used when page content consists of one or few configuration entries which have to describe many fields of each entry. Also, this option is simpler to read (and write) from a code perspective. We need to provide .Key and .Value only once in the form and then repeat .FieldPath, .FieldValue and .FieldType for every field from json object (sample have 4 fields from one json object):

<!-- Would be simpler to store reusable parameters into variables -->
{{ $key10 := "my-app::Company::::sample::draft-sample-v1" }}
{{ $v10 := index.Data.CaFaApi.ConfigurationMap $key10 }}

<!-- Single input form -->
<form method="post">
  <!-- Form control parameter allows to link this form to the specific model -->
  <input type="hidden" name="postActionEntity" value="JsonConfigurationSingleInput">
  <!-- fields related to config entry -->
  <input type="hidden" name="CaFaApi.JsonConfigurationSingleInput.Key" value="{{ $key10 }}">
  <input type="hidden" name="CaFaApi.JsonConfigurationSingleInput.Value" value="{{ $v10 }}">

  <!-- multiple fields from config json value -->
  {{ $path101 := "content.node-obj.child" }}
  <input type="hidden" name="CaFaApi.JsonConfigurationSingleInput.FieldPath" value={{ $path101 }}>
  <label for="formInputValue101">{{ $path101 }}</label>
  <input type="text" id="formInputValue101"
         name="CaFaApi.JsonConfigurationSingleInput.FieldValue"
         value="{{ jsonLookup $v10 $path101 }}">
  <input type="hidden" name="CaFaApi.JsonConfigurationSingleInput.FieldType"
         value="{{ jsonType $v10 $path101 }}">

  {{ $path102 := "content.node-bool" }}
  <input type="hidden" name="CaFaApi.JsonConfigurationSingleInput.FieldPath"
         value={{ $path102 }}>

  <label for="formInputValue102">{{ $path102 }}</label>
  <input type="text" id="formInputValue102"
         name="CaFaApi.JsonConfigurationSingleInput.FieldValue"
         value="{{ jsonLookup $v10 $path102 }}">
  <input type="hidden" name="CaFaApi.JsonConfigurationSingleInput.FieldType"
         value="{{ jsonType $v10 $path102 }}">

  {{ $path103 := "content.node-num" }}
  <input type="hidden" name="CaFaApi.JsonConfigurationSingleInput.FieldPath"
         value={{ $path103 }}>
  <label for="formInputValue103">{{ $path103 }}</label>
  <input type="text" id="formInputValue103"
         name="CaFaApi.JsonConfigurationSingleInput.FieldValue"
         value="{{ jsonLookup $v10 $path103 }}">
  <input type="hidden" name="CaFaApi.JsonConfigurationSingleInput.FieldType"
         value="{{ jsonType $v10 $path103 }}">

  {{ $path104 := "status" }}
  <input type="hidden" name="CaFaApi.JsonConfigurationSingleInput.FieldPath"
         value={{ $path104 }}>
  <label for="formInputValue104">{{ $path104 }}</label>
  <input type="text" id="formInputValue104"
         name="CaFaApi.JsonConfigurationSingleInput.FieldValue"
         value="{{ jsonLookup $v10 $path104 }}">
  <input type="hidden" name="CaFaApi.JsonConfigurationSingleInput.FieldType"
         value="{{ jsonType $v10 $path104 }}">

  <input type="submit" value="Submit draft-sample-v1">
</form>

Bulk input sample

Single input (model JsonConfigurationSingleInput) form is scoped around multiple configuration entries, meaning that when submitting the form, then every entry would be processed and updated in the database in the loop (uniqueness defined by $key’s). It is less performant in comparison with Single input saving (later will be processed asynchronously, meaning without performance loss, but for now prefer using single input option).

This option should be used when page content consists of many entries having few field inputs for each. Also, this option is harder to read (and write) from a code perspective. We need to provide .Key, .Value, .FieldPath, .FieldValue and .FieldType for every field from json objects. We have here 6 entries, please note that final field declaration assumes that field may not exist and type can be selected.

<form method="post" id="cafa-conf-single-edit-form">
  <input type="hidden" name="postActionEntity" value="JsonConfigurationBulkInput">

  {{ $key := "my-app::Company::::sample::draft-sample-v1" }}
  {{ $v := index .Data.CaFaApi.ConfigurationMap $key }}
  <div class="flex-row">
    {{ $path := "content.node-obj.child" }}
    <input type="hidden" name="CaFaApi.JsonConfigurationBulkInput.Key" value="{{ $key }}">
    <input type="hidden" name="CaFaApi.JsonConfigurationBulkInput.Value" value="{{ $v }}">
    <input type="hidden" name="CaFaApi.JsonConfigurationBulkInput.FieldPath" value="{{ $path }}">

    <p>(Config object) {{ $key }}</p>
    <div class="form-field">
      <label for="formInputValue1">{{ $path }}:</label>
      <input type="text" id="formInputValue1"
             name="CaFaApi.JsonConfigurationBulkInput.FieldValue"
             value="{{ jsonLookup $v $path }}">
    </div>
    <div class="form-field">
      <input type="text" name="CaFaApi.JsonConfigurationBulkInput.FieldType"
             value="{{ jsonType $v $path }}" readonly>
    </div>
  </div>

  <div class="flex-row">
    {{ $path4 := "status" }}

    <input type="hidden" name="CaFaApi.JsonConfigurationBulkInput.Key" value="{{ $key }}">
    <input type="hidden" name="CaFaApi.JsonConfigurationBulkInput.Value" value="{{ $v }}">
    <input type="hidden" name="CaFaApi.JsonConfigurationBulkInput.FieldPath" value="{{ $path4 }}">

    <p>(Config object) {{ $key }}</p>
    <div class="form-field">
      <label for="formInputValue4">{{ $path4 }}:</label>
      <input type="text" id="formInputValue4"
             name="CaFaApi.JsonConfigurationBulkInput.FieldValue"
             value="{{ jsonLookup $v $path4 }}">
    </div>
    <div class="form-field">
      <input type="text" name="CaFaApi.JsonConfigurationBulkInput.FieldType"
             value="{{ jsonType $v $path4 }}" readonly>
    </div>
  </div>

  {{ $key2 := "my-app::Company::::sample::draft-sample-v2" }}
  {{ $v2 := index .Data.CaFaApi.ConfigurationMap $key2 }}
  <div class="flex-row">
    {{ $path2 := "content.node-bool" }}
    <input type="hidden" name="CaFaApi.JsonConfigurationBulkInput.Key" value="{{ $key2 }}">
    <input type="hidden" name="CaFaApi.JsonConfigurationBulkInput.Value" value="{{ $v2 }}">
    <input type="hidden" name="CaFaApi.JsonConfigurationBulkInput.FieldPath" value={{ $path2 }}>

    <p>(Config object) {{ $key2 }}</p>
    <div class="form-field">
      <label for="formInputValue2">{{$path2}}:</label>
      <input type="text" id="formInputValue2"
             name="CaFaApi.JsonConfigurationBulkInput.FieldValue"
             value="{{ jsonLookup $v2 $path2 }}">
    </div>
    <div class="form-field">
      <input type="text" name="CaFaApi.JsonConfigurationBulkInput.FieldType"
             value="{{ jsonType $v2 $path2 }}" readonly>
    </div>
  </div>

  <div class="flex-row">
    {{ $path3 := "status" }}
    <input type="hidden" name="CaFaApi.JsonConfigurationBulkInput.Key" value="{{ $key2 }}">
    <input type="hidden" name="CaFaApi.JsonConfigurationBulkInput.Value" value="{{ $v2 }}">
    <input type="hidden" name="CaFaApi.JsonConfigurationBulkInput.FieldPath" value={{ $path3 }}>

    <p>(Config object) {{ $key2 }}</p>
    <div class="form-field">
      <label for="formInputValue3">{{ $path3 }}:</label>
      <input type="text" id="formInputValue3"
             name="CaFaApi.JsonConfigurationBulkInput.FieldValue"
             value="{{ jsonLookup $v2 $path3 }}">
    </div>
    <div class="form-field">
      <input type="text" name="CaFaApi.JsonConfigurationBulkInput.FieldType"
             value="{{ jsonType $v2 $path3 }}" readonly>
    </div>
  </div>

  <div class="flex-row">
    {{ $path5 := "content.node-num" }}
    <input type="hidden" name="CaFaApi.JsonConfigurationBulkInput.Key" value="{{ $key2 }}">
    <input type="hidden" name="CaFaApi.JsonConfigurationBulkInput.Value" value="{{ $v2 }}">
    <input type="hidden" name="CaFaApi.JsonConfigurationBulkInput.FieldPath" value={{ $path5 }}>

    <p>(Config object) {{ $key2 }}</p>
    <div class="form-field">
      <label for="formInputValue5">{{ $path5 }}:</label>
      <input type="text" id="formInputValue5"
             name="CaFaApi.JsonConfigurationBulkInput.FieldValue"
             value="{{ jsonLookup $v2 $path5 }}">
    </div>
    <div class="form-field">
      <input type="text" name="CaFaApi.JsonConfigurationBulkInput.FieldType"
             value="{{ jsonType $v2 $path5 }}" readonly>
    </div>
  </div>

  <div class="flex-row">
    <!-- if path doesn't exist, then it will be added (injected) into the json -->
    {{ $path6 := "not-exist" }}
    <input type="hidden" name="CaFaApi.JsonConfigurationBulkInput.Key" value="{{ $key2 }}">
    <input type="hidden" name="CaFaApi.JsonConfigurationBulkInput.Value" value="{{ $v2 }}">
    <input type="hidden" name="CaFaApi.JsonConfigurationBulkInput.FieldPath" value={{ $path6 }}>

    <p>(Config object) {{ $key2 }}</p>
    <div class="form-field">
      <label for="formInputValue6">{{ $path6 }}:</label>
      <input type="text" id="formInputValue6"
             name="CaFaApi.JsonConfigurationBulkInput.FieldValue"
             value="{{ jsonLookup $v2 $path6 }}">
    </div>
    <div class="form-field">
      <label for="formInputValue7">Type:</label>
      {{ $t := jsonType $v2 $path6 }}
      <!-- All available json types listed here -->
      <select name="CaFaApi.JsonConfigurationBulkInput.FieldType" id="formInputValue7">
        <option value="Boolean" {{ if eq $t "Boolean"}} selected {{ end }}>Boolean</option>
        <option value="String" {{ if eq $t "String"}} selected {{ end }}>String</option>
        <option value="Number" {{ if eq $t "Number"}} selected {{ end }}>Number</option>
        <option value="JSON" {{ if eq $t "JSON"}} selected {{ end }}>JSON</option>
      </select>
    </div>
  </div>

  <input type="submit" value="SAVE">
</form>

Test data for samples

Sample code uses some fake data. To make it work properly, please populate your test account with this fake data (or replace all parameters with your data structure). Just insert this data into CAFA storage using any rest client.

[
  {
    "application": "my-app",
    "level": "Company",
    "level_id": "",
    "type": "sample",
    "name": "draft-sample-v1",
    "value": {
      "content": {
        "node-bool": true,
        "node-num": 112,
        "node-obj": {
          "child": "nested content"
        }
      },
      "status": "draft"
    },
    "added": 0,
    "addedby_id": 0,
    "changed": 0,
    "changedby_id": 0
  },
  {
    "application": "my-app",
    "level": "Company",
    "level_id": "",
    "type": "sample",
    "name": "draft-sample-v2",
    "value": {
      "content": {
        "node-bool": false,
        "node-num": 123777,
        "node-obj": {
          "child": "nested content two"
        }
      },
      "status": "draft"
    },
    "added": 0,
    "addedby_id": 0,
    "changed": 0,
    "changedby_id": 0
  }
]

Session storage

Inbuilt method to store data to the server session and then later reuse it in the same session. Data is held in the session until it is active, logging out will remove the data.

This can be used as an alternative to shared data between pages or applications. Data stored here is available for all applications.

Store data

Values will always be stored as strings. If we want to store other types then we can use one of the helper functions to cast it into different types.

{{ .Storage.Set "key" "value" }}

Store data using input

Simple set command

Starting from version 1.193.0, GoErp provides option to save data through form input parameters, using reserved name Storage.Set. The value of input should have specific syntax, which allows to pass key and value in one string. The syntax is {key}->{value}, where {key} is a key for entry and {value} is actual value In the next sample we are setting entry storage value to the storage under key unique_key.

<form method="post">
    <input type="text" name="Storage.Set" value="unique_key->storage value">
    <button type="submit">Submit </button>
</form>

Using instructions to save data from response payload in dynamics

There is also option to save data from response of specific request to the storage using instructions. Instructions for dynamics using same searching patterns like in chaining:

<form method="post">
  <input type="hidden" name="CRMApi.Api.Post.createCustomer" value="v1/customers/individuals">
  <input type="text" name="CRMApi.Api.Json.createCustomer.string.firstName" value="cool name">

  <!-- Save newly created customer ID to the storage -->
  <input type="hidden" name="Storage.SetInstruction" 
         value="new_customer_id->createCustomer.Response.id">

  <button type="submit">Submit</button>
</form>

<!-- Retrieve new customer ID on next page or even in the same page. -->
<br/>New customer ID: {{ .Storage.Get "new_customer_id" }}

Saving specific parameters from models to storage

Models also supports saving some data based on the path, but options are very restricted in comparison with dynamics. While using save instructions with models, it is possible to save primitive types only (like strings, integers, etc.). Although, there is option to access arrays by using the index.

Some rules that need to take into account when using storage:

  • In the same context .Set and .SetInstruction always called first and .Get call performed last.

Sample for public pages (referring to the customer object which is not available in private):

<h1>Create sales doc</h1>
<form method="post">
    <input type="hidden" name="postActionEntity" value="SalesDocumentInput"/>
    <!-- take customer ID from the session, which will not expose it on the page -->
    <input type="hidden" name="ErplyApi.SalesDocumentInput.CustomerID<-" value="Session.customer.ID">
    <!-- Send instruction for the storage to save the newly created sales document ID to the storage 
         under name new-invoice-id. Choose any name as you want. -->
    <input type="hidden" name="Storage.SetInstruction" value="new-invoice-id->ErplyApi.SalesDocument.ID">
  
    <!-- here goes all other inputs related to the sales document -->

    <button type="submit">Create</button>
</form>

<!-- get newly created document ID from the storage -->
New sales doc ID: {{ .Storage.Get "new-invoice-id" }}

<h1>Get sales documents using models and linking</h1>
<input name="Preset.ErplyApi.SalesDocumentQuery.ClientID<-" value="Session.customer.ID">

<ul>
    {{ range $row := .Data.ErplyApi.SalesDocumentList }}
    <!-- Use any of the available fields here -->
    <li>{{ $row.ID }} | {{ $row.Number }} | {{ $row.ClientID }}</li>
    {{ end }}
</ul>

Reading data

{{ .Storage.Get "key" }}

Automation

Create api request instruction configurations that can be either triggered based on events or timed triggers. Automation instructions are json structures where we use the same templating functionalities that we use to generate pages to generate json instead.

Automation configuration is on the ‘Automation’ tab on the right side menu, and it is only available for ‘page’ or ‘automation’ type templates. Page types are deprecated for automations, they will still work but syntax corrections and new features will not be supported on that type.

Warning

As of 1.228+ automation no longer use jwt’s to operate. Sessions are used instead, this also means that regular user permissions are being used. Automations use a dedicated user group called ‘automation-group’, if some of the api’s are giving access errors (example: 1060 on erply api) then the rights on this group need to be adjusted according to the api calls used.

The user group will be generated automatically once a template is either saved or installed.

Workflow

Automation templates are processed twice, first when forming the json and second when the post operations are being triggered.

  1. Template formation step, on this step the json instruction is formed. All preset calls are processed and can be used in the json instruction generation.
    1. Calls defined in the url configuration presets are triggered on this step
    2. All template functions will run
    3. Result needs to be clean and valid json
  2. Post operations trigger step, on this step the api calls defined in the post operations are triggered.
    1. Calls made in the url configuration cannot be chained to the post operation calls, but the values can just be printed to the values as they are triggered in different steps
  3. Stage call step. Read staged automation at the end of this page for more information.

Automation Automation

Automation triggers

Automation triggering uses hmac (sha256) keys for authentication. Each automation page has a separate unique key that is occasionally refreshed (refresh will also re-register all webhook triggers).

By default, the key is not visible. Installation, store update or manual triggering will generate one in the ui.

Automation trigger Automation trigger

Structure

Basic structure consists of ‘postOperations’ and ’enabled’ keys. Post operations is a key/value set of parameters similar to what is being used on templates. Enabled flag can be used to toggle the automation feature on and off.

{
    "postOperations": [
        {
          "call": "value"
        }
    ],
    "enabled": true
}

Post operations

Post operations are input parameters on what should be done. These are the same instructions that are being used on regular html templates. Dynamic api instructions are also supported.

{
    "postOperations": [
        {
            "ErplyApi.Api.Post.createProduct": "saveProduct",
            "ErplyApi.Api.PostParam.createProduct.groupID": "1",
            "ErplyApi.Api.PostParam.createProduct.code": "Code",
            "ErplyApi.Api.PostParam.createProduct.nameENG": "Name"
        }
    ],
    "enabled": true
}

Sets of instructions can be separated by bucketing the instructions, to make the run on separate times. Dynamic api chaining is also supported here.

Warning

Separate sets here cannot access data between themselves. For example dynamic api chains cannot chain data from an api call of a separate instruction set. See staging instead for more complex automations.

{
    "postOperations": [
        {
            "ErplyApi.Api.Post.createProduct1": "saveProduct",
            "ErplyApi.Api.PostParam.createProduct1.groupID": "1",
            "ErplyApi.Api.PostParam.createProduct1.code": "Code 1",
            "ErplyApi.Api.PostParam.createProduct1.nameENG": "Name 1"
        },
        {
            "ErplyApi.Api.Post.createProduct2": "saveProduct",
            "ErplyApi.Api.PostParam.createProduct2.groupID": "1",
            "ErplyApi.Api.PostParam.createProduct2.code": "Code 2",
            "ErplyApi.Api.PostParam.createProduct2.nameENG": "Name 2"
        }
    ],
    "enabled": true
}

Url configuration

Url configuration’s static presets work the same way on automations as they do on regular templates. These calls are processed when the instruction is generated and cannot be chained into the post operation calls. To use these values in post operations, we can safely just print the values of the calls.

All calls both preset and post operations however are available for stages to be read under the ParentRequests map.

Reading webhook data

Webhook data can be read the limited model based support:

  • wm_sales_document -> .Data.Automation.SalesDocumentList
  • wm_customer -> .Data.Automation.PaymentList
  • wm_payment -> .Data.Automation.PaymentList

In this data the model data matches that of the data source naming for Erply Api

Supports limited data.

{
    "postOperations": [
        {{ range $index, $customer := .Data.Automation.CustomerList }}
            {
                "EMSApi.SendEmailInput.Subject": "Customer {{ $customer.CustomerID }} created",
                "EMSApi.SendEmailInput.To": "{{ $customer.Email }}",
                "EMSApi.SendEmailInput.Content": "<encode><h1>Hello {{ $customer.FullName }}</h1></encode>",
                "EMSApi.SendEmailInput.ContentType": "html"
            }
        {{ end }}
    ],
    "enabled": true
}

Or all possible values using the json parameter reading (supports all the same functionality as dynamic api parameter reading).

Supports all data.

{
    "postOperations": [
        {{ range $customer := (.Data.Automation.Request.Get "items").Array }}
            {
                "EMSApi.SendEmailInput.Subject": "Customer {{ $customer.Get "CustomerID" }} created",
                "EMSApi.SendEmailInput.To": "{{ $customer.Get "Email" }}",
                "EMSApi.SendEmailInput.Content": "<encode><h1>Hello {{ $customer.Get "FullName" }}</h1></encode>",
                "EMSApi.SendEmailInput.ContentType": "html"
            }
        {{ end }}
    ],
    "enabled": true
}

Testing & custom webhooks

Warning

As of 1.288+ the new automation types have the ‘TEST’ button in the editor instead of the preview. This test allows us to define the hook data (not used for timed triggers) and test the execution of the automation without the need to construct the hmac ourselves. Output will contain details if it was successful.

In order to test the functionality without using an actual webhook we can construct the webhook data and send it against the automation endpoint using a generated hmac key.

Warning

To use the following features please make sure that a key has been generated for the page (use the refresh button if not). Automation will still work if a key is not present (as one will be automatically generated for it) but in order to test that everything works it’s always best to generate it manually first.

Custom webhook

For custom webhooks use the page trigger key and generate a hmac for the entire json body. Add the key as a header ’trigger-key’ to the request and send it to the appropriate cluster automation endpoint.

/api/automation/{YOUR_CLIENTCODE}/automation-demo-page

Generate the hmac key with the trigger key from automation tab as a secret using sha256 method. Use the entire json body as content.

The same method can be used for any custom webhook triggers.

Erply webhook

Erply webhooks use a specific structure.

```json
{
    "id": "{HOOK_ID}",
    "hmac": "{HMAC}",
    "clientCode": "{CLIENT_CODE}",
    "table": "wm_customer",
    "action": "update",
    "eventCount": 1,
    "items": [
        {
            "rowId": 18651,
            "timestamp": "2022-05-18 15:10:46",
            "data": {
                "id": 18651,
                "code": "Auto-Prod-Code-3",
                "name": "Generated by automation 3"
            }
        }
    ]
}

For these use the trigger key from the automation tab as secret using sha256 method. Use the id of the webhook as content.

Chaining automation data into api calls

We can also chain automation webhook data into api requests, using the following url configuration as a sample

ServiceApi.Api.Get.getWorkorders: api/v1/workorder
ServiceApi.Api.Query.getWorkorders.<-id: Automation.items.0.id

We can also use a special helper modifier function to get the oldest date out of the webhook items. The argument ‘2006-01-02T15:04:05Z07:00’ is the format that the date should be converted to (depends on the api used).

The function will return the oldest date in the items and then the used api should filter items based on that.

ServiceApi.Api.Get.getWorkorders: api/v1/workorder
ServiceApi.Api.Query.getWorkorders.<-createdAtStart: Automation.items|@getOldestItemDate:2006-01-02T15:04:05Z07:00

Automation page could look something like this then

{
    "postOperations": [
        {{ range .Data.ServiceApi.Api.Requests.getWorkorders.Response.Array }}
        {
            "test": "{{ .Get "id" }}"
        },{{ end }}],
    "enabled": true
}

Logs

From 1.228+ the automations that have been linked to an application will log the failures in the application logs. Similarly, the debug flag for automation logs works with automations as well.

Staging

Only available for the ‘automation’ file types.

This allows us to make separate stages to automations (up to 5). Useful for cases where we need to perhaps construct data for the final operations, and we need access data from the original postOperations api calls.

For example api calls defined in postOperations the sample below are executed at the end of the automation, so logic to read the data cannot be added.

{
    "postOperations": [
        {
            "ErplyApi.Api.Post.createProduct1": "saveProduct",
            "ErplyApi.Api.PostParam.createProduct1.groupID": "1",
            "ErplyApi.Api.PostParam.createProduct1.code": "Code 1",
            "ErplyApi.Api.PostParam.createProduct1.nameENG": "Name 1"
        }
    ],
    "enabled": true
}

Define a stage

To define a second stage we use the stageTo instruction, name here is the name of the automation to use and data is representation of the data that we access from .Data.Automation.Request. The data here needs to be a json string.

We can use the jsonSet, jsonSetObj and jsonDel helpers to manipulate possible json for this input.

The entire automation will complete and then when successful it will execute the second stage when defined. Note that the second stages can access all the api requests made in the first automation without the need to pass that data specifically to the next.

{
    "postOperations": [
        {
            "ErplyApi.Api.Post.createProduct1": "saveProduct",
            "ErplyApi.Api.PostParam.createProduct1.groupID": "1",
            "ErplyApi.Api.PostParam.createProduct1.code": "Code 1",
            "ErplyApi.Api.PostParam.createProduct1.nameENG": "Name 1"
        }
    ],
    "stageTo": {
        "name": "da-at2-automation",
        "data": "{}"
    },
    "enabled": true
}

Optionally we can also define an execution behaviour (runBehaviour) for the stage, available options are:

  1. skip - stage will not be triggered if any of the postOperations on the parent automation fail (default value)
  2. always - stage will always be triggered, and it does not matter if the parent stage is successful or fails
  3. fail - stage will only be triggered if the parent automation fails

The field is optional and will use ‘skip’ by default.

    ...,
    "stageTo": {
        "name": "da-at2-automation",
        "data": "{}",
        "runBehaviour": "always"
    },
    ...

If the parent does indeed fail then we can access the failure message in the stages request data object,

.Data.Automation.Request.Get "parentError"

Define multiple stages

There is option to define multiple stages in array, but only one will be executed based on the runBehaviour of the stage and the result of the parent.

{
   "stageToOneOf": [
      {
         "name": "pg-aut2-automation"
      },
      {
         "name": "pg-autFail-automation",
         "runBehaviour": "fail"
      }
   ]
}

Construct and read data for second stage

Warning

Maximum amount of stages is 10

Adding items the data object

{{ $myJson := `{}` }}

<!-- Add to array -->
{{ $myJson = jsonSet $myJson "myArr." "test-1" }}
{{ $myJson = jsonSet $myJson "myArr. "test-2" }}

<!-- Add entire json strings (arrays or objects) -->
{{ $myJson = jsonSetObj $myJson "myData2" `[5,6]` }}

{
    "postOperations": [
        {
            "CaFaApi.Api.Get.getConfig": "configuration",
            "CaFaApi.Api.Query.getConfig.application": "GOERP"
        }
    ],
    "stageTo": {
        "name": "da-at2-automation",
        "data": {{ $myJson }}
    },
    "enabled": true
}

To read the api calls from the parent stage we can use the parentRequests data element. Note that all calls from the parent are accessible like this both calls made with presets and post operations.

If the parent defines multiple calls with the same name then only the last one called is accessible (for example in a loop). Loop index can be used to make unique names for the requests.

{
    "postOperations": [
        {{ range $index, $key := (.Data.Automation.Request.Get "myArr").Array }}
        {
            "Test": "{{ $key }}",
            "Test2": "{{ $.Data.Automation.ParentRequests.CaFaApi.getConfig.Response.Get "0.name" }}"
        },
        {{ end }}
    ],
    "enabled": true
}

Subsections of Automation

Custom event triggers

It’s possible to trigger automations from the template code by defined conditions, existence of specific parameters or by submitting a form.

Trigger automations from forms

To trigger an automation we need to add the form parameter with name AutomatApi.AutomationEvent.Name the value of it should be the automation we want to trigger.

<!DOCTYPE html>
<html>

    <head></head>

    <body>

        <form method="post">
            <input type="hidden" name="AutomatApi.AutomationEvent.Name" value="da-at1-automation">

            <button type="submit">Save</button>
        </form>
    </body>

</html>

Trigger automations on page load

To trigger automation immediately when the page is rendered we can use the tools’ helper. Note that this means that the automation will always trigger when the page is opened.

{{ .Tools.AutomationEvent "da-at1-automation" }}

Trigger automations conditionally on page load

Sometimes we might want to trigger the automations conditionally on page load, for this the regular if conditions apply. Wrapping these into the conditions will only make the run when the condition is truthful.

<!-- The automation will only be triggered if there is a custom parameter called triggerMyAutomation in the request -->
{{ if .Data.Parameters.triggerMyAutomation }}
    {{ .Tools.AutomationEvent "da-at1-automation" }}
{{ end }}

Passing custom data to automation triggers

We can also optionally pass custom data to the automations in json format. This can be some specific id’s, codes etc. You can pass as many parameters as you want.

Also, there is option to define id of the automation event, which is useful when we need to ensure that automation process were executed only once during processing time. See example below for each option.

Forms

Note that the value should be in valid json format. The json helpers might be useful here.

<!DOCTYPE html>
<html>

    <head></head>

    <body>

        <form method="post">
            <input type="hidden" name="AutomatApi.AutomationEvent.Name" value="da-at1-automation">
            <input type="hidden" name="AutomatApi.AutomationEvent.ID" value="uuid-or-something">
            <input type="hidden" name="AutomatApi.AutomationEvent.Data" value='{"browser": "{{ .Browser.Name }}"}'>

            <button type="submit">Save</button>
        </form>
    </body>

</html>

On automatic load

Using the jsonSet helper will make this a bit simpler.

{{ .Tools.AutomationEvent "da-at1-automation" (jsonSet `{}` "browser" .Browser.Name) "uuid-or-something" }}

Reading the parameters on the automation

To access the parameters we use the regular automation request syntax (.Data.Automation.Request). Structure after the Request.Get is based on the json we sent as input.

{
    "postOperations": [
        {
            "IntLogApi.Api.Post.myRequest1": "v1/log",
            "IntLogApi.Api.Json.myRequest1.application": "da-custom-events",
            "IntLogApi.Api.Json.myRequest1.string.value.visited": "{{ dtCurrent.Unix }}", 
            "IntLogApi.Api.Json.myRequest1.string.value.browser": "{{ .Data.Automation.Request.Get "browser" }}"
        }
    ],
    "enabled": true
}

Installer automations

Version 1.254+

It’s possible to trigger automations when an application is installed or updated. This allows the automations to set up some application specific configurations (example: cafa configurations or base kvs data).

For this create an automation and set its name in the application edit screen.

Install automation Install automation

Note

Installation will not fail if the automations fail or do not exist, so make sure the given automation exists and is linked to the application.

Install automation special data

There are 2 special fields available when the automation is triggered from the installation.

  • fromVersion - the version number the user had before, value will be 0 if this is a fresh install
  • toVersion - the version that is being installed

These values can be used to produce certain conditions for the automations.

{{ $fromVersion := .Data.Automation.Request.Get "fromVersion" }}
{{ $toVersion := .Data.Automation.Request.Get "toVersion" }}

{
    "postOperations": [
        {
            "CustomApi.Api.Post.myRequest1": "some-api-call"
        }
    ],
    "enabled": true
}

Server side events

Use server side events to stream data onto the application templates. This allows us to create dynamic operations in the templates to load specific parts without reloading the entire page.

Any page type can be streamed, but the pages that are streamed should be created specifically for that purpose.

Read about more advanced methods to manipulate the streams

Defining the loader

For the feature to work we need to load the erp pkg script package. We can use the tools’ helper .Tools.LoadSSE that will create the required tag with a dynamic source automatically.

Without this package the sse feature will not work.

<!DOCTYPE html>
<html>
    <head>
        {{ .Tools.LoadSSE }}
    </head>
    <body>
    </body>
</html>

Define the stream target

Any html node can be the target.

The following parameters/attributes are required

  1. id - the node has to have a unique id
  2. data-sse-src - the source of the stream attribute, for this we can again use the tools’ helper .Tools.GetSSESrc with input parameter of the page that we want to stream

Optional parameters/attributes

  1. data-sse-behaviour - the method that is used to handle the stream
    • load - the default value, stream will end once it responds with full data
    • replace - stream will constantly run, and it will replace all the contents of the container node with the events data
    • push - stream will constantly run, and it will push new content at the end of the nodes content

Contents of th steam can be either simple any type the page template is defined for.

Load behaviour

Using the load behaviour we can create a loader type, the data inside the container will be loaded immediately and once the event returns the data then the stream is automatically closed

<!DOCTYPE html>
<html>
    <head>
        {{ .Tools.LoadSSE }}
    </head>
    <body>
        <!-- data-sse-behaviour="load" is the default behaviour and can be skipped if load is used -->
        <div id="t1"
             data-sse-src="{{ .Tools.GetSSESrc "da-sse-data-page"}}">
    
            <!-- contents will be removed once data is 'finished' so loader elements can be added here  -->
            <div class="loader"></div>
        
        </div>
    </body>
</html>

Replace behaviour

In this behaviour mode the data of the element will always get replaced when new data is being returned in the stream. The stream will be open until it returns an error or the window is closed.

Useful to propagate notifications or states.

<!DOCTYPE html>
<html>
    <head>
        {{ .Tools.LoadSSE }}
    </head>
    <body>
        <!-- data-sse-behaviour="replace" is important here to keep the stream open -->
        <div class="my-notification-container" id="t1"
             data-sse-src="{{ .Tools.GetSSESrc "da-sse-notifications-page"}}"
             data-sse-behaviour="replace">
        </div>
    </body>
</html>

Push behaviour

Perhaps the least useful behaviour. Data from the stream is added at the end of the nodes content without replacing the existing data. The stream will be open until it returns an error or the window is closed.

Possibly useful for cases where the data is some sort of log.

<!DOCTYPE html>
<html>
    <head>
        {{ .Tools.LoadSSE }}
    </head>
    <body>
        <!-- data-sse-behaviour="push" is important here to keep the stream open -->
        <div class="my-log-container" id="t1"
             data-sse-src="{{ .Tools.GetSSESrc "da-sse-log-page"}}"
             data-sse-behaviour="push">
        </div>
    </body>
</html>

Prepend behaviour

Data from the stream is added at the front of the nodes content without replacing the existing data. The stream will be open until it returns an error or the window is closed.

<!DOCTYPE html>
<html>
    <head>
        {{ .Tools.LoadSSE }}
    </head>
    <body>
        <!-- data-sse-behaviour="push" is important here to keep the stream open -->
        <div class="my-log-container" id="t1"
             data-sse-src="{{ .Tools.GetSSESrc "da-sse-log-page"}}"
             data-sse-behaviour="prepend">
        </div>
    </body>
</html>

Preload behaviours

The following extra behaviours can be used to first load data and then make it wait for the action to load more data.

Useful for cases where we want to render the content right away in its current state, but we do not want it to re-render all the time.

  • loadAndReplace
  • loadAndPush
  • loadAndPrepend

Multiple loaders

A page can define multiple loaders, but it’s limited to 6 streams per browser (total of all tabs).

<!DOCTYPE html>
<html>
    <head>
        {{ .Tools.LoadSSE }}
        <link rel="stylesheet" href="{{ .Tools.StaticLink "da-sse-styles-css" }}">
    </head>
    <body>
        <div class="column full mt-2">
            <div class="row">
                <span class="alert" id="notifications"
                    data-sse-src="{{ .Tools.GetSSESrc "da-sse-notifications-page"}}"
                    data-sse-behaviour="replace"></span>
            </div>

            <div class="column full justify-center mt-2" id="t1"
                data-sse-src="{{ .Tools.GetSSESrc "da-sse-data-page"}}">
                
                <div class="row justify-center">
                    <div class="column">
                        <div class="loader"></div>
                        <p class="text-center">Loading</p>
                    </div>
                </div>

            </div>
        </div>
    </body>
</html>

Subsections of Server side events

Actions and custom data

From version 1.241+

Normally the SSE feature will continue to stream new data, this streams the same contents in a loop, and in most cases is not needed. To improve this we can use special custom actions on streams to only flush data when there is an actual change.

Define the custom action to the SSE block

To make the server side events flush data based on actions we need to add the action name to the sse stream. The name (my-action) is custom that you can define yourself.

<div class="row">
    <span class="alert" id="notifications"
        data-sse-src="{{ .Tools.GetSSESrc "da-sse-notifications-page"}}"
        data-sse-behaviour="push"
        data-sse-action="my-action">
    </span>
</div>

When this is loaded the stream will not flush any data until that action has been detected.

Send the action

We can use two different methods to send the actions. After the server receives the event it will flush the stream.

Form based actions

With form based actions all possible inputs are visible in the template html code.

<form method="post">
    <input type="hidden" name="AutomatApi.StreamActionEvent.Name" value="my-action">
    <button type="submit">Trigger action</button>
</form>

Template driven action

With this the data is hidden from the html code. Suitable for actions that contain data that we do not want the end user to see or edit.

We still use a form but what is being sent is hidden.

    <form method="post">
        <input type="hidden" name="Send" id="send" value="1">
        <button type="submit" class="form-button">Trigger action</button>
    </form>

    {{ if .Data.Parameters.Send }}
        {{ .Tools.StreamActionEvent "my-action" }}
    {{ end }}

Passing data with the action

We can also pass data with the action trigger. We can then read the data on the possible streamed page and change the rendered content based on the input.

The data should be in json format but the structure is custom.

With forms

With the regular forms data manipulation is limited. Also note that everything sent like this will be visible in the html code and can be adjusted by the user.

<form method="post">
    <input type="hidden" name="AutomatApi.StreamActionEvent.Name" value="my-action">
    <input type="hidden" name="AutomatApi.StreamActionEvent.Data" value=`{"key": "value"}`>
    <button type="submit">Trigger action</button>
</form>

Template action

All template sent data will be hidden, and we can safely add data from session or other values that we do not want the user to edit or see.

    <form method="post">
        <div class="form-input">
            <label for="name">Name</label>
            <input name="Name" id="name" value="{{ .Data.Parameters.Name }}">
        </div>

        <div class="form-input">
            <label for="send">Action</label>
            <input name="Send" id="send" value="{{ .Data.Parameters.Send }}">
        </div>

        <button type="submit" class="form-button">Submit</button>
    </form>

    {{ if .Data.Parameters.Send }}

        {{ $data := printf 
            `{"type": "%s", "name": "%s"}` 
            .Data.Parameters.Send 
            .Data.Parameters.Name
        }}

        {{ .Tools.StreamActionEvent "my-action" $data }}

    {{ end }}

Reading action data

We can read the data that is being passed with the actions and use it to change the data that we render.

We assume the data is:

{
  "name": "Some user",
  "content": "some text" 
}

The get method here is the same as elsewhere with dynamics.

<h4> {{ .Data.Stream.Get "name" }} </h4>
<p>
<!-- Render server time when content is 'clock' and the content itself when not -->
{{ if eq (.Data.Stream.Get "content").String "clock" }}
    {{ dtCurrent }}
{{ else }}
    {{ .Data.Stream.Get "content" }}
{{ end }}

HTML Stream

This feature can be used to make pages that load a large amount or make a api calls that can take time to load a bit friendlier to the users.

Note

In version 1.259.14 there is a limitation due to gzip encoding, that will not properly load the features of the stream.

To make the feature work the page name needs to be appended with a “.l” or “.load” suffix, this will disable the gzip encoder on the server for the request.

“my-data-page” -> “my-data-page.load”

This limitation will be removed in the future.

Load break

To activate the stream feature we only need to add the custom node. Server will stream everything until this point, and the rest when it has completed all the api requests and processes. Note that the node itself will not be printed.

Note

Webkit (safari and chrome in ios) has a different first paint rule scheme, it will not make the first paint if the rendered content is not within a certain threshold (~500b), hidden nodes, css or js does not count towards this check.

This mean that if it does not load there then the first visible bytes need to be increased

<load-break></load-break>

Examples

Regular html

In this example the loaded data will remain. Something would need to be implemented to hide it once loaded (js or css)

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
        content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
</head>
<body>
    <input type="hidden" name="ErplyApi.Api.Post.myRequest1"
        value="getSalesDocuments" data-preset-val="getSalesDocuments">
    <input type="hidden" name="ErplyApi.Api.PostParam.myRequest1.recordsOnPage"
        value="200" data-preset-val="200">

    <main>
        <div>
            <h2>Please wait...</h2>
        </div>
        
        <!-- Background color bytes block, so the size is reached for webkit first paint (safari) -->
        <h2 style="color:white;">Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's
            standard
            dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type
            specimen
            book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially
            unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and
            more
            recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.</h2>

    </template>
    <!-- Everything up until this point will be in the first paint -->
    <load-break></load-break>
    <ul>
        {{ range (.Data.ErplyApi.Api.Requests.myRequest1.Response.Get "records").Array }}
            <li>{{ .Get "id" }} !</li>
        {{ end }}
    </ul>
    </main>
</body>
</html>

Shadow dom

In here is an example where we use shadow dom to make the data replacement to the slot. This will not need any help from js.

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
        content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
</head>
<body>
    <input type="hidden" name="ErplyApi.Api.Post.myRequest1"
        value="getSalesDocuments" data-preset-val="getSalesDocuments">
    <input type="hidden" name="ErplyApi.Api.PostParam.myRequest1.recordsOnPage"
        value="200" data-preset-val="200">

    <main>
    <template shadowrootmode="open">
        <!-- Note that by current spec shadow dom does not load styles from parent window, we would need to load
         these separately if we have some css/js dependencies-->
        <slot name="content">
            <div>
                <h2>Please wait...</h2>
            </div>
            <!-- Background color bytes block, so the size is reached for webkit first paint (safari) -->
            <h2 style="color:white;">Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's
                standard
                dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type
                specimen
                book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially
                unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and
                more
                recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.</h2>
        </slot>
    </template>
    <!-- Everything up until this point will be in the first paint -->
    <load-break></load-break>
    <div slot="content">
        <ul>
            {{ range (.Data.ErplyApi.Api.Requests.myRequest1.Response.Get "records").Array }}
                <li>{{ .Get "id" }} !</li>
            {{ end }}
        </ul>
    </div>
    </main>
</body>
</html>

Subsections of Template applications

Create

Basic info

To use applications for files, first we need to create an application entity.

Use the CREATE NEW -> Create new application from the menu to create one. Give the application a good clean name, it should follow the following rules:

  1. Not contain any special characters
  2. Start with a capital letter

You can link templates to the application from the application view screen, or you can link the template to the application in the template view under the ‘Publish settings’ right panel.

Note that templates in the application should have a unique name. Recommendation is to use some kind of prefix related to the application to describe the templates.

Example: My Test App -> mta-my-page

The linked templates list can be used to make the application create links to the application. There are 2 kinds of links

  1. Open menu link - this is the link that is created on the store application card, under the ‘Open’ button.
  2. Menu link - this is an actual link that is added to the Erply menu

Template list Template list

Green icon on the link button means a link has been defined.

Types

Currently, the application can be either a full, asset or module.

Read more about application types

Use the ‘Publish settings -> Module replacement’ configuration on templates to same what template its meant to replace as a module. Note that this setting is only used on module app’s and the replacement template type cannot be different from the current templates.

Add module replacement Add module replacement

Note

Asset and module type’s use a different kind of rating scheme and are not shown in the shop by default (visible with a filter).

Adding assets or modules to applications

Only full application can have assets and modules. In the application edit screen navigate to the asset or module connection area (under templates).

Note

Note that the asset or module needs to be published to the store first.

  1. Add assets/modules using the add button.
  2. Update connected versions by just re-adding them.

Create version and publish

In order to share the application to other accounts we need to publish to the store. Before the application can be published it needs to meet the following conditions:

  1. Have publishing permissions on the account for the application uuid
  2. Version needs to pass the application validation rules
    1. Have a rate higher than 0
      1. Valid application name, description and short description
      2. Not include partials or static files that are not used by the application
    2. Not include any critical validation errors
      1. Unlinked partials or static files
      2. Links to untrusted sources
    3. Not use file names that are already used by another application
    4. Has at-least one ‘Open’ menu link
    5. has at-least one Erply menu link

To publish the application the workflow would be the following: Validate version -> Create version -> Publish to the selected cluster

Application types

Applications have 3 different types

Full (default)

Standalone application, selected by default when creating a new application

Module

Extension to a specific full application. Files in the module can be set as replacements for files in the target full application.

Modules will be installed when the full app is installed automatically but the replacements will not be enabled until the user uses the configure modules functionality to enable it.

There is no limit to how many modules can be enabled at the same time, but only a single module can be active if multiple of them target the same file in the full application. Goerp will generate the module configuration groups based on this - modules that target unique files will be in a separate group and modules that target the same files will be in the same group.

Asset

Applications that can be shared between multiple applications.

Used to share styles, js functions or general purpose partials/pages.

Note that the version of the asset in the application indicates the minimum required version of the asset, installation will make sure that at-least this version is installed. It will not downgrade the version on the account if it has a higher version installed.

Asset package creator needs to make sure that updates to the app does not break used elements in previous versions, if this cannot be done then a new application should be created instead.

Logs

Application logs

The system collects application based errors into a short-lived memory that we can access for approximate 1 hour.

This can be used for debugging purposes or to check if the application is causing errors.

These logs are only captured for items that are connected to an application.

What is being recorded

  1. Failures in the editor for application connected templates
  2. Failures in page view’s for any possible api’s or the view generation logic itself
  3. Optionally the ‘debugDynApi=1’ can be added to the page parameters to record even successful requests. Note! If the template was recently linked to an application and the template was not save/updated then it might need a new re-save to properly start reading the debug logs.

This will make the application records all requests it makes even successful ones.

Where can the logs be seen

  1. Template edit view under the ‘Application logs’ right section. Right side menu Right side menu
  2. Under local application edit view. Content here is minimal as the api request data is not displayed.
  3. Under store application installation view. Content here is minimal as the api request data is not displayed.

Store errors indicator

The store will also indicate with a bug icon for the application if it has recently encountered errors.

Note that this will also capture template editing errors and can be used to reference encountered errors.

Moving applications

Moving application between accounts without using the store

Applications can be moved from one account to another without using the store interface. For this we can export the application and then import it to the other account using the export and import functionality.

Edit the application under ‘Developer mode’

Locate the application Locate the application

Move to the ‘Export’ tab

Open the ‘Export’ tab and click on the ‘Export’ button to download the application package

Open export tab Open export tab

Import the application

Open the account you wish to move the application to and open the ‘Developer mode’ view. Hover to the ‘Applications’ row to make the import button visible. On the new modal select the previously exported zip file and click ‘Import’.

Select the package Select the package

Wait for the process to complete. After this all the application should be available on the account.

Variables

Application static variables

Applications can use application accessible static variables that can then be used on all application connected templates.

Useful when a similar value is being used in multiple templates and for optimization where a static value is faster than trying to fetch one from an api.

These values can also be assigned into dynamic api requests parameters.

Create variables

In order to use the feature, first we need to create a ‘variables’ type file that is connected to the application.

Note

Application should only have a single variables file, if multiple are connected only the first one of them will be used.

Add vars file Add vars file

Contents

Contents of the variables file is in json format. The structure can have any kind of nesting or value types.

{
    "someKey": "someValue",
    "stuff": {
        "other": [
            {
                "id": 10
            },
            {
                "id": 12
            }
        ]
    },
    "defaultProductID": 21
}

Usage

The values can just be printed to the template or be used in dynamic api chaining features. The reading syntax is the same as reading dynamic api responses so all the same features and rules apply. More about dynamic response reading

Simple read

Reading values from the sample above

{{ .Variables.Get "someKey" }}
{{ .Variables.Get "stuff.other.#.id" }}

In dynamic api chaining

Reading value from the sample above

<input type="hidden" name="ErplyApi.Api.Post.myRequest1"
	data-preset-val="getProducts">
<input type="hidden" name="ErplyApi.Api.PostParam.myRequest1.<-productID"
    data-preset-val="Variables.defaultProductID">

Frequently asked questions

Q: Is there some way to mark which cals in chain are important, and which not (if they failed just keep going)?

A: All chains are important, if one fails then all subsequent requests will be skipped. There is option to make link optional and not skip. More about chaining.

Q: Why in dynamics when I’m using Form.Redirect, GoErp actually not redirecting to the page?

A: Redirect would trigger only when all the calls in the dynamic request set are succeeded and redirect link is valid. More about dynamic redirect

Q: How to use path parameters to load separate pages?

A: Construct main page as a data router and load html or partials based on path parameters.

/shop/products
/shop/services
{{ if eq .Data.Parameters.path1 "products" }}
    {{ template "products-partial" . }}
{{ else if eq .Data.Parameters.path1 "services" }}
    {{ template "services-partial" . }} 
{{ end }}

Q: Getting the error Template 'my-page' seems to be linking to an external source 'https://my.css' that are currently not allowed on publish

A: Since there is no security guarantees for outsourced content, it is recommended to use one of the provided methods for storage instead. If nothing is possible then create a whitelist request for the source.

Q: Template breaks on if conditions

A: The if condition is type sensitive, if the input values are of incomparable types then the template rendering will break from that point forward. Make sure the types used have been converted to the correct types EX: (.Int for dynamic responses and toInt for everything else if dealing with integers).

Q: Use multiple if conditions

A: The syntax for multiple if conditions is as follows:

MultiConditionOperator can be “and” or “or”

{{ if {MultiConditionOperator}
    ({condition1})
    ({condition2})
}}

Q: Chaining from Session or Parameters does not seem to work

A: The chain syntax values can be different from the values that we use it to print values to the templates. This is due to the internals that chaining works with json and the template print works with structs.

Use toJson helper to find out the correct values that should be used when chaining.

{{ .Session toJson }} <!-- Use the find out what the correct key names are for chaining --> 
{{ .Session.User.ID }} <!-- When getting the value to template -->
<input value="Session.user.id"> <!-- When using as a chain value -->

Q: Unable to log into public page

A: Make sure your using the public route path of the page. Customer login does not work with regular handlers.

Q: @pimFilter not working on nested values

A: Make sure the nested values are defined using -> instead of . as the . is reserved here for dynamics.

@pimFilter(name.en,=,string) <!-- incorrect syntax -->
@pimFilter(name->en,=,string) <!-- correct syntax -->

Q: After doing a change operation (create, update or delete) the list of items is not immediately updated (needs another refresh to update)

A: Likely due to incorrect order of dynamic calls. Use ‘|1’ to set the order for the calls and make sure the request that fetches the list is done last.

Q: Not seeing results for created dynamic api calls

A: Either add the errors block (Code samples -> error) to the page or connect the template to an application and check the Application logs tab for results if the calls.

Q: Get current users back office url

A: Users back office link is stored in the session ‘{{ .Session.User.BOLoginUrl }}’

Q: How to concatenate strings and variables using printf

A: printf is a general go template function that can be used to assemble strings from other strings and variables. It is mapped to the go function fmt.Sprintf, which means that it supports all options that are described under Printing topic.

{{ $intVal := 56 }}
{{ $strVal := "foo" }}
{{ $structVal := .Session.Language }}

Result: {{ printf "My int: %d; my string: %s; my struct: %#v" $intVal $strVal $structVal }}

Result

Result: My int: 56; my string: foo; my struct: models.Language{Code:"en", LegacyCode:"eng"}

Q: Where can I get timezone while handling dates in templates?

A: Almost every account have default time related configurations, like timezone and formats. Timezone may not exist for accounts that shares many timezones (e.g. most of the USA accounts), in this case timezone should be configured by account owners.

Available variables:

{{ $timeZone := .Data.ErplyApi.ConfigurationList.timezone }} <!-- Europe/Tallinn -->
{{ $dateFormat := .Data.ErplyApi.ConfigurationList.go_date_format }} <!-- 02.01.2006 -->
{{ $monthDayFormat := .Data.ErplyApi.ConfigurationList.go_month_day_format }} <!-- 02.01 -->
{{ $timeFormat := .Data.ErplyApi.ConfigurationList.go_time_format }} <!-- 15:04:05 -->
{{ $hourMinuteFormat := .Data.ErplyApi.ConfigurationList.go_hour_minute_format }} <!-- 15:04 -->
{{ $dateTimeFormat := .Data.ErplyApi.ConfigurationList.go_date_time_format }} <!-- 02.01.2006 15:04:05 -->

General

CRSF

The server will start to generate a random crsf token for each request. The token is provided in the domain specific cookie, and does not require any specific changes from the templates. The feature will be handled automatically by the server.

Access tokens

Note

Note that the access tokens feature is a work in process feature and will likely have changes going forward.

Access tokens features allows data to be protected using special user defined tokens with api’s that support it.

The update will have 2 separate processes

  1. Regular session handling where access tokens will be taken from user record
  2. B2b session handling where access tokens will be taken from the logged in customer record

Initial version will be looking for the tokens on the record attributes. Expected name syntax on the records is:

access_token-{api}-{optional id value}
  • The {api} part can either contain the value of ‘all’ where it will be used for every api call or a specific name according to goerp api names (ex: KvsApi) where it will only be sent to that specific api.
  • The optional -{optional id value} end value can just be used to identify separate tokens
<!-- Examples of attribute names -->
access_token-ServiceApi-x2
access_token-KvsApi-x
access_token-all-x

The attributes need to be of the type string, and the value of it is the token.

Goerp will automatically parse all keys that match this syntax and send all of them to the api call headers.

Automatic and manual steps

Note

This process will be done automatically for GET requests only, for POST, PUT, PATCH and DELETE the developer needs to make the app add the appropriate token to the accessToke header.

For get requests all the found tokens will be sent, if you want to send a specific token then you need to manually set the accessToken header to the request. For all other types the accessToken header needs to be passed as a header parameter.

Reading current access tokens

Access tokens are stored in the session object (for both the regular and b2b user).

We can attempt to get the tokens directly by a lowercase goerp api name. Note that this is an array and needs to be converted to a valid string (comma separated string) if using for an api request.

{{ .Session.AccessTokens.ApiTokens.serviceapi }}

We can also access named tokens by the name (the name in this case is the third part of the attribute access_token-ServiceApi-{name}). Same as with the api token the result is an array as it contains all attributes with that name.

<!-- access_token-ServiceApi-x2 -->
{{ .Session.AccessTokens.NamedTokens.x2 }}}

We can use helpers to get a valid header value (comma separated string when multiple)

GetForApi

Return all tokens for an api as a comma separated string. Note that the input name is not case-sensitive.

{{ .Session.AccessTokens.GetForApi "all" }}
{{ .Session.AccessTokens.GetForApi "serviceapi" }}

GetByName

Return all tokens that match the unique name (third part of the attributes).

<!-- access_token-ServiceApi-x2 -->
{{ .Session.AccessTokens.GetByName "x2" }}

Subsections of EXAMPLES

Automation

Complex 2-stage automation

This example demonstrates how to create a 2-stage automation process.

  • The first stage collects initial data to be able to generate new set of requests that cannot be performed using regular chaining functionality. Also, it demonstrates how to pass data to the next stage.
  • Second stage uses data that was passed from the first stage in combination with parent requests that was performed on the first stage.

First stage

{{- $warehouses := .Data.AccountAdminApi.Api.Requests.getWarehouses.Response.Get "data.warehouses" -}}
{{- $wIds := mkStringArray }}
{
    "postOperations": [
    {{- range $i, $warehouse := $warehouses.Array -}}
    {{- $wId := ($warehouse.Get "id").String -}}
    {{if $i}},{{end}}{
        "ErplyApi.Api.Post.getProductStock{{$i}}": "getProductStock",
        "ErplyApi.Api.PostParam.getProductStock{{$i}}.warehouseID": "{{ $wId }}",
        "ErplyApi.Api.PostParam.getProductStock{{$i}}.getSuggestedPurchasePrice": "1"
    }
    {{- $wIds = addToStringArray $wIds $wId -}}
    {{ end }}
    ],
    "stageTo": {
        "name": "pg-aut2-automation",
        "data": {"wids": {{ $wIds | toJson }}}
    },
    "enabled": true
}

Second stage

{{- $wids := (.Data.Automation.Request.Get "wids").Array -}}
{{- if .Data.Automation.ParentRequests.ErplyApi -}}
    {
    "postOperations": [
    {{- range $i, $wid := $wids -}}
        {{- $whStockResponse := index $.Data.Automation.ParentRequests.ErplyApi (printf "getProductStock%d" $i) -}}
        {{- $whStockWithNegativeAmounts := ($whStockResponse.Response.Get "records.#(amountInStock<0)#").Array -}}
        {{- if not $whStockWithNegativeAmounts -}}
            {{- continue -}}
        {{- end -}}
        {{if $i}},{{end}}{
            "ErplyApi.Api.Post.newRegistraton{{$i}}": "saveInventoryRegistration",
            "ErplyApi.Api.PostParam.newRegistraton{{$i}}.warehouseID": "{{ $wid }}",
            {{- range $ii, $el := $whStockWithNegativeAmounts -}}
            {{- $rowIndex := toInt (add $ii 1) -}}
            {{- $rowAmount := ($el.Get "amountInStock").String -}}{{if $ii}},{{end}}
                "ErplyApi.Api.PostParam.newRegistraton{{$i}}.productID{{$rowIndex}}": "{{$el.Get "productID"}}",
                "ErplyApi.Api.PostParam.newRegistraton{{$i}}.amount{{$rowIndex}}": "{{ replace $rowAmount "-" "" 1 }}",
                "ErplyApi.Api.PostParam.newRegistraton{{$i}}.price{{$rowIndex}}": "{{$el.Get "suggestedPurchasePrice"}}"
            {{- end -}}
        }
    {{- end -}}
    ],
    "enabled": true
}
{{- end -}}

JSON query (gjson)

There is no way to match multiple values in one run, but you can use the pipe | operator to run multiple searches in a row.

Query sample

friends.#(first!=Dale)#|#(first!=Roger)#.last
// Result: ["Murphy","Smith"]

Code sample

In template code:

{{ $res.Get "friends.#(first!=Dale)#|#(first!=Roger)#.last" }}
// With more pipes:
{{ $res.Get "friends.#(first!=Dale)#|#(first!=Roger)#.last|@unique|@commaSepStr" }}
// Result: "Murphy,Smith"

While chaining requests:

<input 
    type="hidden"
    name="CustomApi.Api.Query.req2.<-names"
    data-preset-val="req1.Response.friends.#(first!=Dale)#|#(first!=Roger)#.last|@unique|@commaSepStr">

JSON payload for references

{
  "name": {
    "first": "Tom",
    "last": "Anderson"
  },
  "age": 37,
  "children": [
    "Sara",
    "Alex",
    "Jack"
  ],
  "fav.movie": "Deer Hunter",
  "friends": [
    {
      "first": "Dale",
      "last": "Murphy",
      "age": 44,
      "nets": [
        "ig",
        "fb",
        "tw"
      ]
    },
    {
      "first": "Roger",
      "last": "Craig",
      "age": 68,
      "nets": [
        "fb",
        "tw"
      ]
    },
    {
      "first": "Jane",
      "last": "Murphy",
      "age": 47,
      "nets": [
        "fb",
        "ig"
      ]
    },
    {
      "first": "John",
      "last": "Smith",
      "age": 49,
      "nets": [
        "tw"
      ]
    }
  ]
}

Public/b2b emailing

Problem with sending emails from public/b2b pages

The regular way exposes all fields to the template - this means anyone can alter the field contents before its being sent. This allows the area to be used for possible phishing attacks.

Secure fields with automations

The best way to hide the fields is to move all functional fields away from the template (in this case into an automation) These automation fields are never exposed to the public and cannot be intercepted.

The following example contains 3 templates:

  1. check-page - the page where the user inputs the email (public page)
  2. check-emailer-automation - the automation tha actually makes the email send
  3. optional - just for an example we parse a template into the email content in the automation (not public)

check-page

In this example we only render the form when nothing is sent, we also never expose the automation trigger to the template, thus a user cannot even alter its execution.

We use the input here to trigger the automation, and the only value that is being passed to the e-mailer.

<h1>Secure emailer</h1>
<p>Email from public/b2b without exposing the fields for editing</p>

<!-- Optional, we render the form only once and display a message once its already sent -->
{{ if .Data.Parameters.inputEmailField }}
    {{ .Tools.AutomationEvent "check-emailer-automation" (jsonSet `{}` "email" .Data.Parameters.inputEmailField) }}

    <h2>Thanks!</h2>
{{ else }}
    <h2>Send me something</h2>
    <form method="post">
        <label for="email">Add your email</label>
        <input type="string" id="email" name="inputEmailField">

        <button type="submit">Send</button>
    </form>
{{ end }}

{{ .Data.Parameters.inputEmailField }}

check-emailer-automation

We read the email from the passed data and use it as the email, rest is hardcoded into the automation. Here we also encode an optional template to the email body.

{
    "postOperations": [
        {
            "EMSApi.SendEmailInput.To": "{{ .Data.Automation.Request.Get "email" }}",
            "EMSApi.SendEmailInput.Subject": "Secure emailer demo",
            "EMSApi.SendEmailInput.ContentType": "html",
            "EMSApi.SendEmailInput.Content": "<encode>{{ .Tools.ExecTemplate "check-done-page" }}</encode>",
            "EMSApi.SendEmailInput.IsEncoded": "true"
        }
    ],
    "enabled": true
}

(optional) check-done-page

The content that will be in the email.

<h1>Thanks! email received</h1>