Commit cf88c24a authored by Carsten  Rose's avatar Carsten Rose
Browse files

Make REST PUT/POST aware of FE Elements, not submitted via JSON

parent 5c7c1e2d
Pipeline #1550 passed with stage
in 2 minutes and 25 seconds
......@@ -9,12 +9,16 @@
..
.. --------------------------------------------------
.. Best Practice T3 reST https://docs.typo3.org/typo3cms/drafts/github/xperseguers/RstPrimer/
.. External Links: `Bootstrap <http://getbootstrap.com/>`_:
.. External Links: `Bootstrap <http://getbootstrap.com/>`_
.. Add Images: https://wiki.typo3.org/ReST_Syntax#Images ...
..
.. Admonitions (https://docs.typo3.org/typo3cms/drafts/github/xperseguers/RstPrimer/#admonitions)
.. .. note:: .. important:: .. tip:: .. warning::
..
.. Definition:
.. some text becomes strong (only one line)
.. description has to indented
.. -*- coding: utf-8 -*- with BOM.
......@@ -7416,6 +7420,182 @@ last used (STORE_USER) or (first time call during browser session) takes the def
tail = </div><p></p>
}
.. _`rest`:
REST
====
Via `REST https://en.wikipedia.org/wiki/Representational_state_transfer`_ it's possible to access the QFQ based
application. Each REST API endpoint has to be defined as a QFQ Form. The QFQ REST api implements the
four most used REST HTTP methods:
GET - Read
Shows a list of database records or a single record. The QFQ form holds the definition which and what to show.
List: ``curl -X GET "http://localhost/qfq/typo3conf/ext/qfq/Source/api/rest.php/person/``
Record with id=123: ``curl -X GET "http://localhost/qfq/typo3conf/ext/qfq/Source/api/rest.php/person/123``
POST - Create new record
The QFQ form defines wich columns will be written in which table. Most of QFQ Form functionality can be used. Example:
``curl -X POST "http://localhost/qfq/typo3conf/ext/qfq/Source/api/rest.php/person/" -d '{"name":"Miller","firstname":"Joe"}'``
PUT - Update a record
Similar to POST, but a given record will be updated.
``curl -X PUT "http://localhost/qfq/typo3conf/ext/qfq/Source/api/rest.php/person/123" -d '{"name":"Miller","firstname":"Joe"}'``
DELETE - Delete a record
Similar to a QFQ Delete form.
``curl -X DELETE "http://localhost/qfq/typo3conf/ext/qfq/Source/api/rest.php/person/123"``
All data is exported in JSON notation.
To define a QFQ form becomes a REST form by enable one or more of:
Form: Access > Permit REST: get / insert / update / delete
Endpoint
^^^^^^^^
.. tip::
The basic REST API endpoint: ``<domain>/typo3conf/ext/qfq/Source/api/rest.ph``
``<domain>/typo3conf/ext/qfq/Source/api/rest.php/<level1>/<id1>/<level2>/<id2>/.../?<var1>=<value1>&...``
Append level names and ids after `.../rest.php/`, each separated by '/' .
E.g.:
1. List of all persons: `<domain>/typo3conf/ext/qfq/Source/api/rest.php/person`
2. Data of person 123: `<domain>/typo3conf/ext/qfq/Source/api/rest.php/person/123`
3. Adresses of person 123: `<domain>/typo3conf/ext/qfq/Source/api/rest.php/person/123/address`
4. Adress details of address 45 from person 123: `<domain>/typo3conf/ext/qfq/Source/api/rest.php/person/123/address/45`
QFQ 'Forms' are used as a 'container' (to define all details).
.. tip::
The QFQ ``form name`` represents the level name.
Only the last <level> of an URI will be processed. The former ones are just to fulfil a good looking REST API.
.. note::
Each level name (=form name) is available via STORE_CLIENT and name `_formX`. E.g. in example
(1) `{{_form1:C:alnumx}}=person` and `{{_form2:C:alnumx}}=address`.
Each level id is available via STORE_CLIENT and name `_idX`. E.g. in example
(2) `{{_id1:C}}=123` and `{{_id2:C}}=45`.
Also the `id` after the last `level` in the URI path, 123 in example (2) and 45 in example (4), is copied to
variable `r` in STORE_TYPO3, access it via `{{r:T}}`.
GET - Read
^^^^^^^^^^
A REST (GET) form has two modes: ::
data
Specific content to a given id. Defined via 'form.parameter.restSqlData'. This mode is selected if there is an
id>0 given.
list
A list of records will be exported. Defined via 'form.parameter.restSqlList'. This mode is selected if there is no
id or id=0.
There are *no* FormElements.
To simplify access to id parameter of the URI, a mapping is possible via 'form.parameter.restParam'.
E.g. `restParam=pId,adrId` with example d) makes `{{pId:C}}=123` and `{{adrId:C}}=45`. The order of variable
names corresponds to the position in the URI. `_id1` is always mapped to the first parameter name, `_id2` to
the second one and so on.
GET Variables provided via URL are available via STORE_CLIENT as usual.
Form:
+-------------------+------------------------------------------------------------------------------+
| Attribute | Description |
+===================+==============================================================================+
| name=<level> | Level name in URI |
+-------------------+------------------------------------------------------------------------------+
| permitNew=rest | The form can be loaded in REST mode with mising parameter 'id' or 'id=0' |
+-------------------+------------------------------------------------------------------------------+
| permitEdit=rest | The form can be loaded in REST mode with parameter 'id' > 0 |
+-------------------+------------------------------------------------------------------------------+
Form.parameter:
+-------------------+------------------------------------------------------------------------------+
| Attribute | Description |
+===================+==============================================================================+
| restSqlData | SQL query selects content shown in data mode. |
| | `restSqlData={{!SELECT id, name, gender FROM Person WHERE id='{{r:T0}}'' }}` |
+-------------------+------------------------------------------------------------------------------+
| restSqlList | SQL query selects content shown in data mode. |
| | `restSqlData={{!SELECT id, name FROM Person }}` |
+-------------------+------------------------------------------------------------------------------+
| restParam | Optional. CSV list of variable names. E.g.: `restParam=pId,adrId` |
+-------------------+------------------------------------------------------------------------------+
| restToken | Optional. User defined string. For dynamic token see below. |
+-------------------+------------------------------------------------------------------------------+
.. note:
There are no `special-column-names`_ available in `restSqlData` or `restSqlList`. Also there are no
SIPs possible, cause REST typically does not offer sessions/cookies (which are necessary for SIPs).
Authorization
-------------
By default, the REST API is public accessible.
If this is not wished, HTTP AUTH might be used (configured via webserver) or the
QFQ internal 'HTTP header token based authorization'.
Token based authorization
^^^^^^^^^^^^^^^^^^^^^^^^^
A form will require a 'token based authorization', as soon as there is a `form.parameter.restToken` defined.
Therefore the HTTP Header 'Authorization' has to be set with `token=<secret token>`. The 'secret token' will
be checked against the server. Using HTTPS, such token can't be sniffed and will typically not be logged in
any server logs.
Example: ::
form.parameter.restToken=myCrypticString0123456789
Test via commandline: curl -X GET -H 'Authorization: Token token=myCrypticString0123456789' "http://localhost/qfq/typo3conf/ext/qfq/Source/api/rest.php/person/123/address/"
The static setup with `form.parameter.restToken=myCrypticString0123456789 is fine, as long as only one token
exist. In case of multiple tokens, replace the static string against a SQL query.
General: The HTML Header Authorization token is available in STORE_CLIENT via '`{{Authorization:C:alnumx}}`.
For example all created tokens are saved in a table 'Auth' with a column 'token'. Define: ::
form.parameter.restToken={{SELECT a.token FROM Auth AS a WHERE a.token='{{Authorization:C:alnumx}}' }}
To restrict access to a subset of data, just save the limitations inside the Auth record and update the query
to check it:
.. code-block:: pmysql
form.parameter.restToken={{SELECT a.token FROM Auth AS a WHERE a.token='{{Authorization:C:alnumx}}'}}
form.parameter.restSqlList={{!SELECT p.id, p.name, p.email FROM Person AS p, Auth AS a WHERE a.token='{{Authorization:C:alnumx}}' AND a.attribute=p.attribute}}
form.parameter.restSqlData={{!SELECT p.* FROM Person AS p, Auth AS a WHERE a.token='{{Authorization:C:alnumx}}' AND a.attribute=p.attribute AND p.id='{{r:T0}}' }}
If authorization is denied, the request will be answered with a delay of 3 seconds (configured via securityFailedAuthDelay).
.. _`system`:
System
......@@ -7605,146 +7785,6 @@ AutoCron / website: HTTPS protocol
This is useful if there is a general 'HTTP >> HTTPS' redirection configured and the website is accessed via `https://localhost/...`
.. _`rest`:
REST
----
QFQ offers an API endpoint for GET (and later POST,PUT,DELETE) operations. ::
<domain>/typo3conf/ext/qfq/Source/api/rest.php/<level1>/<id1>/<level2>/<id2>/.../?<var1>=<value1>&...
Append level names and ids after `rest.php/...`, separated by '/' each.
E.g.:
1. List of all persons: `<domain>/typo3conf/ext/qfq/Source/api/rest.php/person`
2. Data of person 123: `<domain>/typo3conf/ext/qfq/Source/api/rest.php/person/123`
3. Adresses of person 123: `<domain>/typo3conf/ext/qfq/Source/api/rest.php/person/123/address`
4. Adress details of address 45 from person 123: `<domain>/typo3conf/ext/qfq/Source/api/rest.php/person/123/address/45`
QFQ 'Forms' are used as a 'container' to configure all necessary export/import details per 'level'.
Each 'level' is represented by a QFQ Form.
Only the last <level> of an URI will be processed. The former ones are just to fulfil a good looking REST API.
.. note::
The level name is the QFQ form name.
Each level name (=form name) is available via STORE_CLIENT and name `_formX`. E.g. in example
(1) `{{_form1:C:alnumx}}=person` and `{{_form2:C:alnumx}}=address`.
Each level id is available via STORE_CLIENT and name `_idX`. E.g. in example
(2) `{{_id1:C}}=123` and `{{_id2:C}}=45`.
Also the `id` after the last `level` in the URI path, 123 in example (2) and 45 in example (4), is copied to
variable `r` in STORE_TYPO3, access it via `{{r:T}}`.
Export (GET)
^^^^^^^^^^^^
All data is exported in JSON notation.
A REST (GET) form has two modes: ::
data
Specific content to a given id. Defined via 'form.parameter.restSqlData'. This mode is selected if there is an
id>0 given.
list
A list of records will be exported. Defined via 'form.parameter.restSqlList'. This mode is selected if there is no
id or id=0.
There are *no* FormElements.
To simplify access to id parameter of the URI, a mapping is possible via 'form.parameter.restParam'.
E.g. `restParam=pId,adrId` with example d) makes `{{pId:C}}=123` and `{{adrId:C}}=45`. The order of variable
names corresponds to the position in the URI. `_id1` is always mapped to the first parameter name, `_id2` to
the second one and so on.
GET Variables provided via URL are available via STORE_CLIENT as usual.
Form:
+-------------------+------------------------------------------------------------------------------+
| Attribute | Description |
+===================+==============================================================================+
| name=<level> | Level name in URI |
+-------------------+------------------------------------------------------------------------------+
| permitNew=rest | The form can be loaded in REST mode with mising parameter 'id' or 'id=0' |
+-------------------+------------------------------------------------------------------------------+
| permitEdit=rest | The form can be loaded in REST mode with parameter 'id' > 0 |
+-------------------+------------------------------------------------------------------------------+
Form.parameter:
+-------------------+------------------------------------------------------------------------------+
| Attribute | Description |
+===================+==============================================================================+
| restSqlData | SQL query selects content shown in data mode. |
| | `restSqlData={{!SELECT id, name, gender FROM Person WHERE id='{{r:T0}}'' }}` |
+-------------------+------------------------------------------------------------------------------+
| restSqlList | SQL query selects content shown in data mode. |
| | `restSqlData={{!SELECT id, name FROM Person }}` |
+-------------------+------------------------------------------------------------------------------+
| restParam | Optional. CSV list of variable names. E.g.: `restParam=pId,adrId` |
+-------------------+------------------------------------------------------------------------------+
| restToken | Optional. User defined string. For dynamic token see below. |
+-------------------+------------------------------------------------------------------------------+
.. note:
There are no `special-column-names`_ available in `restSqlData` or `restSqlList`. Also there are no
SIPs possible, cause REST typically does not offer sessions/cookies (which are necessary for SIPs).
Authorization
^^^^^^^^^^^^^
By default, the REST API is public accessible.
If this is not wished, HTTP AUTH might be used (configured via webserver) or the
QFQ internal 'HTTP header token based authorization'.
Token based authorization
'''''''''''''''''''''''''
A form will require a 'token based authorization', as soon as there is a `form.parameter.restToken` defined.
Therefore the HTTP Header 'Authorization' has to be set with `token=<secret token>`. The 'secret token' will
be checked against the server. Using HTTPS, such token can't be sniffed and will typically not be logged in
any server logs.
Example: ::
form.parameter.restToken=myCrypticString0123456789
Test via commandline: curl -X GET -H 'Authorization: Token token=myCrypticString0123456789' "http://localhost/qfq/typo3conf/ext/qfq/Source/api/rest.php/person/123/address/"
The static setup with `form.parameter.restToken=myCrypticString0123456789 is fine, as long as only one token
exist. In case of multiple tokens, replace the static string against a SQL query.
General: The HTML Header Authorization token is available in STORE_CLIENT via '`{{Authorization:C:alnumx}}`.
For example all created tokens are saved in a table 'Auth' with a column 'token'. Define: ::
form.parameter.restToken={{SELECT a.token FROM Auth AS a WHERE a.token='{{Authorization:C:alnumx}}' }}
To restrict access to a subset of data, just save the limitations inside the Auth record and update the query
to check it:
.. code-block:: pmysql
form.parameter.restToken={{SELECT a.token FROM Auth AS a WHERE a.token='{{Authorization:C:alnumx}}'}}
form.parameter.restSqlList={{!SELECT p.id, p.name, p.email FROM Person AS p, Auth AS a WHERE a.token='{{Authorization:C:alnumx}}' AND a.attribute=p.attribute}}
form.parameter.restSqlData={{!SELECT p.* FROM Person AS p, Auth AS a WHERE a.token='{{Authorization:C:alnumx}}' AND a.attribute=p.attribute AND p.id='{{r:T0}}' }}
If authorization is denied, the request will be answered with a delay of 3 seconds (configured via securityFailedAuthDelay).
.. _applicationTest:
Application Test
......
......@@ -67,7 +67,7 @@ const SQL_FORM_ELEMENT_BY_ID = "SELECT * FROM FormElement AS fe WHERE fe.id = ?"
const SQL_FORM_ELEMENT_RAW = "SELECT * FROM FormElement AS fe WHERE fe.formId = ? AND fe.deleted = 'no' AND fe.enabled='yes' ORDER BY fe.ord, fe.id";
const SQL_FORM_ELEMENT_SPECIFIC_CONTAINER = "SELECT *, ? AS 'nestedInFieldSet' FROM FormElement AS fe WHERE fe.formId = ? AND fe.deleted = 'no' AND FIND_IN_SET(fe.class, ? ) AND fe.feIdContainer = ? AND fe.enabled='yes' ORDER BY fe.ord, fe.id";
const SQL_FORM_ELEMENT_ALL_CONTAINER = "SELECT *, ? AS 'nestedInFieldSet' FROM FormElement AS fe WHERE fe.formId = ? AND fe.deleted = 'no' AND FIND_IN_SET(fe.class, ? ) AND fe.enabled='yes' ORDER BY fe.ord, fe.id";
const SQL_FORM_ELEMENT_SIMPLE_ALL_CONTAINER = "SELECT fe.id, fe.feIdContainer, fe.name, fe.label, fe.type, fe.encode, fe.checkType, fe.checkPattern, fe.mode, fe.modeSql, fe.parameter, fe.dynamicUpdate FROM FormElement AS fe, Form AS f WHERE f.name = ? AND f.id = fe.formId AND fe.deleted = 'no' AND fe.class = 'native' AND fe.enabled='yes' ORDER BY fe.ord, fe.id";
const SQL_FORM_ELEMENT_SIMPLE_ALL_CONTAINER = "SELECT fe.id, fe.feIdContainer, fe.name, fe.value, fe.label, fe.type, fe.encode, fe.checkType, fe.checkPattern, fe.mode, fe.modeSql, fe.parameter, fe.dynamicUpdate FROM FormElement AS fe, Form AS f WHERE f.name = ? AND f.id = fe.formId AND fe.deleted = 'no' AND fe.class = 'native' AND fe.enabled='yes' ORDER BY fe.ord, fe.id";
const SQL_FORM_ELEMENT_CONTAINER_TEMPLATE_GROUP = "SELECT fe.id, fe.name, fe.label, fe.maxLength, fe.parameter FROM FormElement AS fe, Form AS f WHERE f.name = ? AND f.id = fe.formId AND fe.deleted = 'no' AND fe.class = 'container' AND fe.type='templateGroup' AND fe.enabled='yes' ORDER BY fe.ord, fe.id";
const SQL_FORM_ELEMENT_TEMPLATE_GROUP_FE_ID = "SELECT * FROM FormElement AS fe WHERE fe.id = ? AND fe.deleted = 'no' AND fe.class = 'container' AND fe.type='templateGroup' AND fe.enabled='yes' ";
//const SQL_FORM_ELEMENT_NATIVE_TG_COUNT = "SELECT fe.*, IFNULL(feTg.maxLength,0) AS _tgCopies FROM FormElement AS fe LEFT JOIN FormElement AS feTg ON fe.feIdContainer=feTg.id AND feTg.deleted = 'no' AND feTg.class = 'container' AND feTg.type='templateGroup' AND feTg.enabled='yes' WHERE fe.formId = ? AND fe.deleted = 'no' AND fe.class = 'native' AND fe.enabled='yes'";
......
......@@ -1454,6 +1454,31 @@ class QuickFormQuery {
}
}
if ($formMode == FORM_REST) {
$method = $this->store::getVar(CLIENT_REQUEST_METHOD, STORE_CLIENT);
if (false === Support::findInSet(strtolower($method), $this->formSpec[F_REST_METHOD])) {
throw new UserFormException("Form '" . $this->formSpec[F_NAME] . "' is not allowed with method '$method'", ERROR_FORM_REST);
}
$this->restCheckAuthToken($this->formSpec[F_REST_TOKEN] ?? '');
switch ($method) {
case REQUEST_METHOD_GET:
break;
case REQUEST_METHOD_POST:
case REQUEST_METHOD_PUT:
$formModeNew = FORM_SAVE;
break;
case REQUEST_METHOD_DELETE:
$formModeNew = FORM_DELETE;
break;
default:
throw new CodeException('Unknown Request Method: ' . $method, ERROR_UNKNOWN_MODE);
}
} else {
switch ($permitMode) {
case FORM_PERMISSION_SIP:
if (!$sipFound || $formNameFoundInStore !== STORE_SIP || $recordIdFoundInStore !== STORE_SIP) {
......@@ -1478,29 +1503,6 @@ class QuickFormQuery {
default:
throw new CodeException("Unknown permission mode: '" . $permitMode . "'", ERROR_FORM_UNKNOWN_PERMISSION_MODE);
}
if ($formMode == FORM_REST) {
$method = $this->store::getVar(CLIENT_REQUEST_METHOD, STORE_CLIENT);
if (false === Support::findInSet(strtolower($method), $this->formSpec[F_REST_METHOD])) {
throw new UserFormException("Form '" . $this->formSpec[F_NAME] . "' is not allowed with method '$method'", ERROR_FORM_REST);
}
$this->restCheckAuthToken($this->formSpec[F_REST_TOKEN] ?? '');
switch ($method) {
case REQUEST_METHOD_GET:
break;
case REQUEST_METHOD_POST:
case REQUEST_METHOD_PUT:
$formModeNew = FORM_SAVE;
break;
case REQUEST_METHOD_DELETE:
$formModeNew = FORM_DELETE;
break;
default:
throw new CodeException('Unknown Request Method: ' . $method, ERROR_UNKNOWN_MODE);
}
}
// Form Definition valid?
......@@ -1512,7 +1514,8 @@ class QuickFormQuery {
return $sipFound;
}
$sipArray = $this->store->getStore(STORE_SIP);
// Check: requiredParameter: '' or 'form' or 'form,grId' or 'form #formname for form,grId'
// Check: requiredParameter: '' or 'form' or 'form,grId' or 'form #formname for form,grId'
$requiredParameter = ($r > 0) ? $this->formSpec[F_REQUIRED_PARAMETER_EDIT] : $this->formSpec[F_REQUIRED_PARAMETER_NEW];
if (trim($requiredParameter) == '') {
......
......@@ -936,10 +936,10 @@ class Save {
return;
}
$fileDestinationSplit = $this->evaluate->parse($formElement[FE_FILE_DESTINATION_SPLIT]);
$fileSplitType = $this->evaluate->parse($formElement[FE_FILE_SPLIT]);
$fileSplitTypeOptions = $this->evaluate->parse($formElement[FE_FILE_SPLIT_OPTIONS]);
$fileSplitTableName = $this->evaluate->parse($formElement[FE_FILE_SPLIT_TABLE_NAME]);
$fileDestinationSplit = $this->evaluate->parse($formElement[FE_FILE_DESTINATION_SPLIT]??'');
$fileSplitType = $this->evaluate->parse($formElement[FE_FILE_SPLIT]??'');
$fileSplitTypeOptions = $this->evaluate->parse($formElement[FE_FILE_SPLIT_OPTIONS]??'');
$fileSplitTableName = $this->evaluate->parse($formElement[FE_FILE_SPLIT_TABLE_NAME]??'');
if (empty($fileSplitTableName)) {
$fileSplitTableName = $this->formSpec[F_TABLE_NAME];
......
......@@ -250,7 +250,7 @@ class FillStoreForm {
throw new CodeException("Missing the " . FE_TYPE_EXTRA . " field '" . $formElement[FE_NAME] . "' in SIP.", ERROR_MISSING_HIDDEN_FIELD_IN_SIP);
}
$newValues[$formElement[FE_NAME]] = $sipValues[$formElement[FE_NAME]];
$newValues[$formElement[FE_NAME]] = $sipValues[$formElement[FE_NAME]] ?? '';
continue;
}
......@@ -276,6 +276,13 @@ class FillStoreForm {
// }
// }
// FORM_REST: typically form elements are filled and created on form load. This does not exist for REST Forms.
// If a FE.value is defined, this has precedence over client supplied content.
if ($formMode == FORM_REST && $formElement[FE_VALUE]!='') {
$clientValues[$clientFieldName] = $this->evaluate->parse($formElement[FE_VALUE]);
}
// copy value to $newValues
if (isset($clientValues[$clientFieldName])) {
......
......@@ -262,18 +262,23 @@ VALUES
(1, 'permitEdit', 'Permit Edit', 'show', 'radio', 'all', 'native', 360, 0, 10,
'<a href="{{documentation:Y}}#form-permitnewedit">Info</a>', '', '', '', 'buttonClass=btn-default', 4, '', '', '',
'specialchar', 'no', ''),
(1, 'escapeTypeDefault', 'Escape type default', 'show', 'radio', 'all', 'native', 370, 0, 10,
(1, 'prestMethod', 'Permit REST', 'show', 'checkbox', 'all', 'native', 370, 0, 10,
'<a href="{{documentation:Y}}#rest">Info</a>', '', '', '', 'buttonClass=btn-default\nitemList=get,post:insert,put:update,delete', 4, '', '', '',
'specialchar', 'no', ''),
(1, 'escapeTypeDefault', 'Escape type default', 'show', 'radio', 'all', 'native', 380, 0, 10,
'<a href="{{documentation:Y}}#variable-escape">Info</a>', '', '', '',
'itemList=c:config,s:single,d:double,l:ldap search,L:ldap value,m:mysql realEscapeString,-:none\nbuttonClass=btn-default',
4, '', '', '', 'specialchar', 'no', ''),
(1, 'dirtyMode', 'Record Locking', 'show', 'radio', 'all', 'native', 380, 0, 10,
(1, 'dirtyMode', 'Record Locking', 'show', 'radio', 'all', 'native', 390, 0, 10,
'<a href="{{documentation:Y}}#locking-record">Info</a>', '', '', '',
'buttonClass=btn-default', 4, '', '', '', 'specialchar', 'no', ''),
(1, 'recordLockTimeoutSeconds', 'Lock timeout (seconds)', 'show', 'text', 'all', 'native', 390, 0, 0,
(1, 'recordLockTimeoutSeconds', 'Lock timeout (seconds)', 'show', 'text', 'all', 'native', 400, 0, 0,
'<a href="{{documentation:Y}}#locking-record">Info</a>', '',
'{{SELECT IF("{{recordLockTimeoutSeconds:R0}}"=0,"{{recordLockTimeoutSeconds:Y0}}","{{recordLockTimeoutSeconds:R0}}")}}',
'', '', 4, '', '', '', 'specialchar', 'no', ''),
(1, 'primaryKey', 'Primary Key', 'show', 'text', 'all', 'native', 400, 0, 0,
(1, 'primaryKey', 'Primary Key', 'show', 'text', 'all', 'native', 410, 0, 0,
'<a href="{{documentation:Y}}#form-primary-key">Info</a>', '', '', '', '', 4, '', '', 'id', 'specialchar', 'no', ''),
# Multi
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment