<?php
/*
* Copyright (C) 2017 Karmabunny Pty Ltd.
*
* This file is a part of SproutCMS.
*
* SproutCMS is free software: you can redistribute it and/or modify it under the terms
* of the GNU General Public License as published by the Free Software Foundation, either
* version 2 of the License, or (at your option) any later version.
*
* For more information, visit <http://getsproutcms.com>.
*/
namespace Sprout\Helpers;
use Exception;
use InvalidArgumentException;
/**
* Processes forms using configuration stored in a JSON file which specifies database columns, their HTML input fields,
* and validation rules.
* A generic implementation which should work for most cases is found in {@see ManagedAdminController::_getEditForm}
* (generates the form) and {@see Controller::saveJsonData} (saves the POST submission)
*/
class JsonForm extends Form
{
/**
* Loads multiedit data for use on a view
* @param array $conf The form config, pulled from JSON
* @param string $default_link The default linking column name
* @param int $record_id The base record ID
* @param array $conditions Conditions to get records in the multiedit table which relate to the base record;
* see {@see Pdb::buildClause}. A clause of 'link_column' => $record_id will be appended to any provided
* conditions.
* @return array The View will be modified
*/
public static
function loadMultiEditData
(array $conf, $default_link, $record_id, array $conditions) {
$data = [];
foreach ($conf as $tab => $tab_content) {
foreach ($tab_content as $item) {
if (!isset($item['multiedit'])) continue;
$multed = $item['multiedit'];
$id = 'multiedit_' . $multed['id'];
$table = $multed['table'];
Pdb::validateIdentifier($table);
$values = [];
$table_conditions = [];
$link_column = !empty($multed['link']) ?
$multed['link'] : $default_link;
if (isset($multed['where'])) $table_conditions = $multed['where']; $table_conditions[] = [$link_column, '=', $record_id];
$table_conditions = array_merge($table_conditions, $conditions); $q = "SELECT * FROM ~{$table} WHERE " . Pdb::buildClause($table_conditions, $values);
if ($multed['reorder']) {
$q .= ' ORDER BY record_order';
}
$data[$id] = Pdb::q($q, $values, 'arr');
}
}
return $data;
}
/**
* Determine the auto-generated default values for an autofill-list
*
* Defaults:
* joiner_local_col Singular of local table name + '_id'
* joiner_foreign_col Singular of the foreign_table option + '_id'
* foreign_label_col 'name'
* reorder false
*
* @param array $auto Auto link opts, from a JSON form defintion
* @param string $local_table_name The 'local' table name (i.e. the table which is being edited)
* @return array
*/
public static
function autofillOptionDefaults
(array $auto, $local_table_name) {
if (empty($auto['joiner_local_col'])) { $auto['joiner_local_col'] = Inflector::singular($local_table_name) . '_id';
}
if (empty($auto['joiner_local_col'])) { $auto['joiner_local_col'] = Inflector::singular($auto['foreign_table']) . '_id';
}
if (empty($auto['foreign_label_col'])) { $auto['foreign_label_col'] = 'name';
}
if (empty($auto['reorder'])) { $auto['reorder'] = false;
}
return $auto;
}
/**
* Loads autofill_list data for use on a view
* @param array $conf The form config, pulled from JSON
* @param string $local_table_name The 'local' table name (i.e. the table which is being edited)
* @param int $local_record_id The local record id
* @param array $conditions Conditions to get records in the joiner table which relate to the base record;
* see {@see Pdb::buildClause}. A clause of 'link_column' => $record_id will be appended to any provided
* conditions.
* @return array The View will be modified
*/
public static
function loadAutofillListData
(array $conf, $local_table_name, $local_record_id, array $conditions) {
$data = [];
foreach ($conf as $tab => $tab_content) {
foreach ($tab_content as $item) {
if (!isset($item['autofill_list'])) continue;
$auto = $item['autofill_list'];
$auto = self::autofillOptionDefaults($auto, $local_table_name);
// If specified use a foreign_label_sql parameter directly
// If foreign_label_col is an array, CONCAT on space
if (isset($auto['foreign_label_sql'])) { $label_sql = $auto['foreign_label_sql'];
} elseif (is_array($auto['foreign_label_col'])) { foreach ($auto['foreign_label_col'] as $col) {
Pdb::validateIdentifier($col);
}
$label_sql = 'CONCAT(item.' . implode(", ' ', item.", $auto['foreign_label_col']) . ')'; } else {
Pdb::validateIdentifier($auto['foreign_label_col']);
$label_sql = 'item.' . $auto['foreign_label_col'];
}
Pdb::validateIdentifier($auto['joiner_local_col']);
Pdb::validateIdentifier($auto['joiner_foreign_col']);
Pdb::validateIdentifier($auto['joiner_table']);
Pdb::validateIdentifier($auto['foreign_table']);
// Need ID for saving, value for display, and orderkey for ordering
$fields = [];
$fields[] = 'item.id';
$fields[] = "{$label_sql} AS value";
if ($auto['reorder']) {
$fields[] = 'joiner.record_order AS orderkey';
}
if ($auto['reorder']) {
$order = 'joiner.record_order';
} else {
$order = $label_sql;
}
$q = "SELECT {$fields}
FROM ~{$auto['joiner_table']} AS joiner
INNER JOIN ~{$auto['foreign_table']} AS item ON item.id = joiner.{$auto['joiner_foreign_col']}
WHERE joiner.{$auto['joiner_local_col']} = ?
ORDER BY {$order}";
$data[$auto['name']] = Pdb::query($q, [$local_record_id], 'arr');
}
}
return $data;
}
/**
* Expands item definitions for a field pulled from JSON
* @param array &$field The field definition
* @param array $metadata Metadata for use in argument replacement
* @return void
*/
public static
function expandItemDefns
(array &$field, array $metadata = []) {
if (!isset($field['attrs'])) $field['attrs'] = []; if (!isset($field['helptext'])) $field['helptext'] = ''; if (!isset($field['required'])) $field['required'] = false; if (!isset($field['items'])) { $field['items'] = [];
return;
}
$items = &$field['items'];
// Use a function to look up or generate items if specified
if (isset($items['func']) and
(count($items) == 1 or
(count($items) == 2 and
isset($items['args'])))) { if (strpos($items['func'], '::') !== false) { $class = Sprout::nsClass($class, ['Sprout\Helpers']);
$func = $class . '::' . $func;
} else {
$func = $items['func'];
}
$args = (isset($items['args']) ?
$items['args'] : []); $args = self::argReplace($args, $metadata);
// Run a SQL query and return a Pdb map
} else if (isset($items['query']) and
(count($items) == 1 or
(count($items) == 2 and
isset($items['binds'])))) { $binds = isset($items['binds']) ?
$items['binds'] : []; $items = Pdb::query($items['query'], $binds, 'map');
// Convert class vars
} else if (isset($items['var']) and
(count($items) == 1)) { $class = Sprout::nsClass($class, ['Sprout\Helpers']);
throw new Exception('Class lookup failed for var: ' . $items['var']);
}
// Chop leading $ to convert to array reference
$items = $class_vars[$var];
// Convert class constants
} else if (isset($items['const']) and
(count($items) == 1)) { $class = Sprout::nsClass($class, ['Sprout\Helpers']);
throw new Exception('Class lookup failed for var: ' . $items['var']);
}
$items = constant($class . '::' . $const);
// Convert constants
} else {
foreach ($items as $key => &$item) {
if (count($item) != 1) continue;
$item_fields = $item;
if (isset($item_fields['const'])) { if (strpos($item_fields['const'], '::') === false) continue; list($class, $const) = explode('::', $item_fields['const']); $class = Sprout::nsClass($class, ['Sprout\Helpers']);
if (defined($class. '::' . $const)) { $item = constant($class. '::' . $const); continue;
}
throw new Exception('Const lookup failed: ' . $item_fields['const']);
}
}
}
}
/**
* Render a tab item, which may be a field, heading, html block, etc
*
* @param array $item The item definition
* @param string $for Either 'add', 'edit' or something custom; to check against the "for" parameter
* @param int $id Record ID; for pass-through to function calls
* @param int $data Data array; for pass-through to function calls
* @param int $errors Errors array; for pass-through to function calls
* @param string $name_prepend Prepended to the field name. Only applies for fields
* @return html
*/
public static
function renderTabItem
(array $item, $for, $id, array $data, array $errors, $name_prepend = '') {
// Metadata which is passed into argReplace for display/validator argument replacement
$metadata = [
'id' => $id,
];
if (isset($item['field'])) { // Field
$field = $item['field'];
if (!isset($field['display']) or
$field['display'] == null) return null; if (isset($field['for'])) { if (!in_array($for, $field['for'])) return null; }
Fb::setFieldValue($name_prepend . $field['name'], $field['default']);
// For fields like Fb::checkboxBoolList(string $name, array $attrs, array $settings),
// $name_prepend doesn't actually get prepended. E.g. 'active' doesn't produce 'm_active' because
// the <input> name is set using the $settings param, not $name like most other field types
Fb::setFieldValue($field['name'], $field['default']);
}
return JsonForm::renderField($field, $name_prepend, $metadata);
} elseif (isset($item['heading'])) { // Heading
return '<h3>' . Enc::html($item['heading']) . '</h3>';
} elseif (isset($item['html'])) { // HTML text
return $item['html'];
} elseif (isset($item['group'])) { // Groups of similar items
$group = $item['group'];
if (empty($group['wrap-class'])) $group['wrap-class'] = ''; if (empty($group['item-class'])) $group['item-class'] = '';
$group['wrap-class'] = trim('field-group-wrap ' . $group['wrap-class']); $group['item-class'] = trim('field-group-item ' . $group['item-class']);
$out = '<div class="' . $group['wrap-class'] . '">';
foreach ($group['items'] as $group_item) {
$out .= '<div class="' . $group['item-class'] . '">';
$out .= self::renderTabItem($group_item, $for, $id, $data, $errors, $name_prepend);
$out .= '</div>';
}
$out .= '</div>';
return $out;
} elseif (isset($item['func'])) { // Call a custom function and return the result
if (strpos($item['func'], '::') !== false) { $class = Sprout::nsClass($class, ['Sprout\Helpers']);
$func = $class . '::' . $func;
} else {
$func = $item['func'];
}
$args = [$id, $data, $errors];
if (isset($item['args'])) { }
$args = self::argReplace($args, $metadata);
} elseif (isset($item['multiedit'])) { // Multiedit
$multed = $item['multiedit'];
if (!isset($data['multiedit_' . $multed['id']])) { $data['multiedit_' . $multed['id']] = [];
}
if (!isset($errors['multiedit_' . $multed['id']])) { $errors['multiedit_' . $multed['id']] = [];
}
// Backup form data, then clobber it, to render using the multiedit's defaults
$original_data = $data;
$data = [];
Fb::setData($data);
$out = '<script type="text/x-template" id="' . Enc::html('multiedit-' . $multed['id']) . '">';
$out .= '<input type="hidden" name="m_id">';
foreach ($multed['items'] as $multi_item) {
$out .= self::renderTabItem($multi_item, $for, $id, $data, $errors, 'm_');
}
$out .= '</script>';
if (!empty($multed['post-add-js'])) { MultiEdit::setPostAddJavaScriptFunc($multed['post-add-js']);
}
if (!empty($multed['reorder'])) { MultiEdit::reorder();
}
MultiEdit::itemName($multed['single']);
// Restore original form data which was clobbered to render defaults
Fb::setData($original_data);
$data = $original_data;
MultiEdit::display(
$multed['id'],
$data['multiedit_' . $multed['id']],
$errors['multiedit_' . $multed['id']]
);
return $out;
} elseif (isset($item['autofill_list'])) { // The autofillList method receives the whole object in $options straight from the JSON
$auto = $item['autofill_list'];
return Form::autofillList($auto['name'], [], $auto);
} else {
throw new InvalidArgumentException(
"Unknown item type; expected key 'field', 'heading', 'html', 'func', 'multiedit', or 'autofill_list'"
);
}
}
/**
* Renders the input for a field definition pulled from a JSON file
* @param array $field The field definition
* @param string $name_prepend Prepended to the field name
* @param array $metadata Metadata for use in argument replacement
* @return string
*/
public static
function renderField
(array $field, $name_prepend = '', $metadata = []) {
self::expandItemDefns($field, $metadata);
$func = $field['display'];
if (strpos($func, '::') !== false) { $class = Sprout::nsClass($class, ['Sprout\Helpers']);
$func = $class . '::' . $func;
}
throw new InvalidArgumentException("Field display method '{$func}' does not exist");
}
Form::nextFieldDetails($field['label'], $field['required'], $field['helptext']);
return Form::fieldAuto($func, $name_prepend . $field['name'], $field['attrs'], $field['items']);
}
/**
* Set a parameter for fields to be a specific value, for one or more columns
*
* @param array $items Items array, may contain fields, groups, etc
* @param array $columns Columns to alter, as an array of strings (e.g. ['file','image'])
* @param string $key Key to set
* @param string $val Value to set the key to
* @return null Array $items is altered in-place
*/
public static
function setParameterForColumns
(array &$items, array $columns, $key, $val) {
foreach ($items as &$item) {
if (isset($item['field']) and
in_array($item['field']['name'], $columns)) { $item['field'][$key] = $val;
} else if (isset($item['group'])) { self::setParameterForColumns($item['group']['items'], $columns, $key, $val);
}
}
}
/**
* Extract field defns from a list (which may include groups)
*
* @param array $items Item defintions, e.g. from a tab
* @return array Field defintions only, in a flat list
**/
public static
function flattenGroups
(array $items) {
$field_defns = [];
foreach ($items as $item) {
if (isset($item['field'])) { $field_defns[] = $item['field'];
} else if (isset($item['group'])) { $field_defns,
self::flattenGroups($item['group']['items'])
);
}
}
return $field_defns;
}
/**
* Collates POST data using specified config options
* @param array $conf Config, typically loaded by Controller::loadFormJson()
* @param string $mode Form mode, e.g. 'add', 'edit' or a custom value
* @param Validator $validator To validate the data; must be created externally so it can be used for other
* validation before and/or after collating the JsonForm data
* @param int $item_id The record being edited or 0 for record adding
* @return array [0] Data for insert/update, field => value [1] Errors generated, field => error
*/
public static function collateData($conf, $mode, Validator $validator, $item_id)
{
$item_id = (int) $item_id;
$data = [];
$errs = [];
foreach ($conf as $tab => $tab_content) {
if ($tab_content === 'categories') {
$data['categories'] = [];
foreach ($_POST['categories'] as $cat_id) {
$cat_id = (int) $cat_id;
if ($cat_id > 0) $data['categories'][] = $cat_id;
}
}
continue;
}
// Metadata which is passed into argReplace for display/validator argument replacement
$metadata = [
'id' => $item_id,
];
// Main fields
$field_defns = self::flattenGroups($tab_content);
foreach ($field_defns as $field_defn) {
if (isset($field_defn['for']) and
!in_array($mode, $field_defn['for'])) continue; $validator->setFieldLabel($field_defn['name'], @$field_defn['label']);
if (strpos($field_defn['name'], ',') === false) { self::collateFieldData($field_defn, @$_POST[$field_defn['name']], $metadata, $validator, $data);
} else {
$errors = [];
foreach (explode(',', $field_defn['name']) as $name) { // Prevent errors from going into main validation until they have been grouped
$segment_validator = new Validator($_POST);
$temp_defn = $field_defn;
$temp_defn['name'] = $name;
self::collateFieldData($temp_defn, @$_POST[$name], $metadata, $segment_validator, $data);
$field_errors = $segment_validator->getFieldErrors();
if (isset($field_errors[$name])) { }
}
foreach ($errors as $err) {
$validator->addFieldError($field_defn['name'], $err);
}
}
}
$errs = array_merge($errs, $validator->getFieldErrors());
// Multiedits
$valid = [];
foreach ($tab_content as $item) {
if (!isset($item['multiedit'])) continue;
$multed = $item['multiedit'];
$src = 'multiedit_' . $multed['id'];
// User has removed all multiedit records of this type
if (!isset($_POST[$src])) continue;
$data[$src] = [];
$valid[$src] = [];
$defaults = [];
$field_defns = self::flattenGroups($multed['items']);
foreach ($field_defns as $field_defn) {
$field = $field_defn['name'];
$defaults[$field] = $field_defn['default'];
}
foreach ($_POST[$src] as $item_num => $val) {
if (!isset($val[$field])) $val[$field] = ''; if (!isset($data[$src][$item_num])) $data[$src][$item_num] = []; if (!isset($errs[$src][$item_num])) $errs[$src][$item_num] = []; if (!isset($valid[$src][$item_num])) $valid[$src][$item_num] = new Validator
([]);
if (!isset($data[$src][$item_num]['id']) and
isset($val['id'])) { $data[$src][$item_num]['id'] = (int) $val['id'];
}
$valid[$src][$item_num]->setFieldValue($field_defn['name'], $val[$field]);
self::collateFieldData(
$field_defn,
$val[$field],
$metadata,
$valid[$src][$item_num],
$data[$src][$item_num]
);
}
}
$errs[$src] = [];
foreach ($valid[$src] as $item_num => $v) {
if ($v->hasErrors()) {
$errs[$src][$item_num] = $v->getFieldErrors();
}
}
// Prune empty records, so user doesn't get an error about their required fields
foreach ($data[$src] as $item_num => $record) {
if (MultiEdit::recordEmpty($record, $defaults)) {
unset($data[$src][$item_num]); unset($errs[$src][$item_num]); }
}
}
}
return [$data, $errs];
}
/**
* Collates a single field's $_POST data for INSERT/UPDATE queries, and performs validation
*
* @param array $field_defn Field definition from JSON file
* @param string $input The POSTed input for the field, usually just from $_POST[field_name]
* @param Validator $valid The validator instance to do validation with
* @param array &$data Data for DB insert/update
*/
protected static
function collateFieldData
(array $field_defn, $input, array $metadata, Validator
$valid, array &$data) {
// Don't save anything for display-only fields
if (isset($field_defn['save']) and
!$field_defn['save']) return;
$field = $field_defn['name'];
$data[$field] = implode(',', $input); } else {
$data[$field] = $input;
}
if (Validator::isEmpty($input)) {
$data[$field] = $field_defn['empty'];
} else {
$data[$field] = '';
}
$valid->setFieldValue($field, $data[$field]);
}
if (!empty($field_defn['required'])) { $valid->required([$field]);
}
if (isset($field_defn['validate'])) { foreach ($field_defn['validate'] as $call) {
if (!isset($call['func'])) continue; if (empty($call['args'])) $call['args'] = [];
$call['args'] = self::argReplace($call['args'], $metadata);
switch (@count($call['args'])) { case 0:
$valid->check($field, $call['func']);
break;
case 1:
$valid->check($field, $call['func'], $call['args'][0]);
break;
case 2:
$valid->check($field, $call['func'], $call['args'][0], $call['args'][1]);
break;
case 3:
$valid->check($field, $call['func'], $call['args'][0], $call['args'][1], $call['args'][2]);
break;
default:
$args = array_merge([$field, $call['func']], $call['args']); break;
}
}
}
}
/**
* Replace magic strings in "args" arrays with various metadata values
*
* Replacements:
* %% The current record id
*
* @param array $args Arguments in the JsonForm definition
* @param array $metadata Metadata array
* @return array Mogrified arguments
*/
private static
function argReplace
(array $args, array $metadata) {
foreach ($args as &$arg) {
if ($arg === '%%') {
$arg = $metadata['id'];
}
}
return $args;
}
/**
* Modify a JSON form config to make a particular field optional
* @param array $conf The JSON form config
* @param string $field_name The name of the field
*/
public static
function makeOptional
(array &$conf, $field_name) {
self::changeFieldRequired($conf, $field_name, false);
}
/**
* Modify a JSON form config to make a particular field required
* @param array $conf The JSON form config
* @param string $field_name The name of the field
*/
public static
function makeRequired
(array &$conf, $field_name) {
self::changeFieldRequired($conf, $field_name, true);
}
/**
* Modify a JSON form config to change the 'required' status of a particular field
* This implements {@see JsonForm::makeOptional} and {@see JsonForm::makeRequired}
* @param array $conf The JSON form config
* @param string $field_name The name of the field
* @param bool $required True for required, false for optional
*/
protected static
function changeFieldRequired
(array &$conf, $field_name, $required) {
$required = (bool) $required;
foreach ($conf as $tab_name => &$tab) {
// Ignore e.g. categories
foreach ($tab as &$item) {
if (isset($item['field'])) { if ($item['field']['name'] != $field_name) continue;
$item['field']['required'] = $required;
return;
} else if (isset($item['group'])) { if (!@is_array($item['group']['items'])) continue; foreach ($item['group']['items'] as &$group_item) {
if (!isset($group_item['field'])) continue; if ($group_item['field']['name'] != $field_name) continue;
$group_item['field']['required'] = $required;
return;
}
}
}
}
}
}