From 85c8d040abde3eaa0a2eaa94277e3035dfd5767a Mon Sep 17 00:00:00 2001 From: Bogdanova Olga Date: Sun, 7 Feb 2021 20:42:29 +0300 Subject: [PATCH] Issues-373: added custom upload permissions --- config/vanilla/bootstrap.late.php | 12 + .../controllers/api/MediaApiController.php | 69 +- .../class.discussioncontroller.php | 2 + .../controllers/class.postcontroller.php | 11 +- .../vanilla/models/class.categorymodel.php | 2 + vanilla/library/core/class.form.php | 3239 +++++++++++++++++ 6 files changed, 3324 insertions(+), 11 deletions(-) create mode 100644 vanilla/library/core/class.form.php diff --git a/config/vanilla/bootstrap.late.php b/config/vanilla/bootstrap.late.php index 2d4ff46..08bacb6 100644 --- a/config/vanilla/bootstrap.late.php +++ b/config/vanilla/bootstrap.late.php @@ -55,4 +55,16 @@ $CategoryModel->recalculateTree(); unset($CategoryModel); } + + + // Define some permissions for the Vanilla categories. + // FIX: https://github.com/topcoder-platform/forums/issues/373 + $PermissionModel->define( + [ + 'Vanilla.Discussions.Uploads' => 0, + 'Vanilla.Comments.Uploads' => 0], + 'tinyint', + 'Category', + 'PermissionCategoryID' + ); } diff --git a/vanilla/applications/dashboard/controllers/api/MediaApiController.php b/vanilla/applications/dashboard/controllers/api/MediaApiController.php index f628445..9898b82 100644 --- a/vanilla/applications/dashboard/controllers/api/MediaApiController.php +++ b/vanilla/applications/dashboard/controllers/api/MediaApiController.php @@ -364,12 +364,6 @@ public function patch_attachment(int $id, array $body): array { * @return array */ public function post(array $body) { - if(!Gdn::session()->checkPermission('Garden.Uploads.Add')) { - throw new ClientException('You don\'t have permission to upload files', 403); - } - - //$this->permission('Garden.Uploads.Add'); - $allowedExtensions = $this->config->get('Garden.Upload.AllowedFileExtensions', []); $uploadSchema = new UploadedFileSchema([ UploadedFileSchema::OPTION_ALLOWED_EXTENSIONS => $allowedExtensions, @@ -380,10 +374,69 @@ public function post(array $body) { $in = $this->schema([ 'file' => $uploadSchema, + 'categoryID:i?' => "CategoryID", + 'discussionID:i?' => "DiscussionID", + 'commentID:i?' => "CommentID", + 'actionType:s?' => "ActionType" ], 'in')->setDescription('Add a media item.'); - $out = $this->schema($this->fullSchema(), 'out'); - $body = $in->validate($body); + $categoryID = $body['categoryID']; + $discussionID = $body['discussionID']; + $commentID = $body['commentID']; + $actionType = $body['actionType']; + + if(!$categoryID && !$discussionID && !Gdn::session()->checkPermission('Garden.Uploads.Add')) { + throw new ClientException("You don't have permission to upload files", 403); + } + + if(!Gdn::session()->checkPermission('Garden.Uploads.Add')) { + switch ($actionType) { + case 'NewDiscussion': + if(!$categoryID) { + throw new ClientException("You don't have permission to upload files", 403); + } + $permissionCategory = CategoryModel::permissionCategory($categoryID); + $discussionsUploads = CategoryModel::checkPermission($permissionCategory, 'Vanilla.Discussions.Uploads'); + if(!$discussionsUploads) { + throw new ClientException("You don't have permission to upload files", 403); + } + break; + case 'EditDiscussion': + $discussionModel = new DiscussionModel(); + $discussion = $discussionModel->getID($discussionID); + if (!$discussion) { + throw new NotFoundException('Discussion'); + } + $categoryID = val('CategoryID', $discussion, false); + $permissionCategory = CategoryModel::permissionCategory($categoryID); + $discussionsUploads = CategoryModel::checkPermission($permissionCategory, 'Vanilla.Discussions.Uploads'); + if(!$discussionsUploads) { + throw new ClientException("You don't have permission to upload files", 403); + } + break; + case 'NewComment': + case 'EditComment': + $discussionModel = new DiscussionModel(); + $discussion = $discussionModel->getID($discussionID); + if (!$discussion) { + throw new NotFoundException('Discussion'); + } + + $categoryID = val('CategoryID', $discussion, false); + $permissionCategory = CategoryModel::permissionCategory($categoryID); + $commentsUploads = CategoryModel::checkPermission($permissionCategory, 'Vanilla.Comments.Uploads'); + // No permissions + if(!$commentsUploads) { + throw new ClientException("You don't have permission to upload files", 403); + } + break; + default: + throw new ClientException("You don't have permission to upload files", 403); + } + + } + + $out = $this->schema($this->fullSchema(), 'out'); $imageExtensions = array_keys(ImageResizer::getExtType()); /** @var UploadedFile $file */ diff --git a/vanilla/applications/vanilla/controllers/class.discussioncontroller.php b/vanilla/applications/vanilla/controllers/class.discussioncontroller.php index 98d91f6..f11785f 100644 --- a/vanilla/applications/vanilla/controllers/class.discussioncontroller.php +++ b/vanilla/applications/vanilla/controllers/class.discussioncontroller.php @@ -257,6 +257,8 @@ public function index($DiscussionID = '', $DiscussionStub = '', $Page = '') { $this->DiscussionID = $this->Discussion->DiscussionID; $this->Form->addHidden('DiscussionID', $this->DiscussionID); $this->Form->addHidden('CommentID', ''); + $this->setData('ActionType', 'NewComment'); + $this->Form->addHidden('ActionType', 'NewComment'); // Look in the session stash for a comment $StashComment = $Session->getPublicStash('CommentForDiscussionID_'.$this->Discussion->DiscussionID); diff --git a/vanilla/applications/vanilla/controllers/class.postcontroller.php b/vanilla/applications/vanilla/controllers/class.postcontroller.php index 21acd6c..a3ffdbb 100644 --- a/vanilla/applications/vanilla/controllers/class.postcontroller.php +++ b/vanilla/applications/vanilla/controllers/class.postcontroller.php @@ -173,7 +173,7 @@ public function discussion($categoryUrlCode = '') { $this->Form->setFormValue('DiscussionID', $this->Discussion->DiscussionID); $this->title(t('Edit Discussion')); - + $this->setData('ActionType', 'EditDiscussion'); if ($this->Discussion->Type) { $this->setData('Type', $this->Discussion->Type); } else { @@ -197,8 +197,10 @@ public function discussion($categoryUrlCode = '') { $this->permission('Vanilla.Discussions.Add'); } $this->title(t('New Discussion')); + $this->setData('ActionType', 'NewDiscussion'); } + $this->Form->addHidden('ActionType', $this->data('ActionType')); touchValue('Type', $this->Data, 'Discussion'); // Remove Announce parameter if it was injected into the form. @@ -443,7 +445,7 @@ public function editDiscussion($discussionID = '', $draftID = '') { $record = $this->Draft = $this->DraftModel->getID($draftID); $this->CategoryID = $this->Draft->CategoryID; $this->setData('ShowPreviewButton', $record->Format != 'Rich'); - + $this->setData('ActionType', 'NewDiscussion'); // FIX: https://github.com/topcoder-platform/forums/issues/347 $this->setData('_CancelUrl', '/drafts'); // Verify this is their draft @@ -452,6 +454,7 @@ public function editDiscussion($discussionID = '', $draftID = '') { } } else { $record = $this->DiscussionModel->getID($discussionID); + $this->setData('ActionType', 'EditDiscussion'); $this->setData('ShowPreviewButton', $this->Discussion->Format != 'Rich'); // FIX: Issues-308: Editor - supporting old and new formats $this->EventArguments['Discussion'] = &$record; @@ -693,7 +696,7 @@ public function comment($DiscussionID = '') { $this->Form->addHidden('DiscussionID', $DiscussionID); $this->Form->addHidden('CommentID', $CommentID); $this->Form->addHidden('DraftID', $DraftID, true); - + $this->Form->addHidden('ActionType', $this->data('ActionType')); // Check permissions if ($Discussion && $Editing) { // Make sure that content can (still) be edited. @@ -987,9 +990,11 @@ public function comment2($commentID, $inserted = false) { */ public function editComment($commentID = '', $draftID = '') { if (is_numeric($commentID) && $commentID > 0) { + $this->setData('ActionType', 'EditComment'); $this->Form->setModel($this->CommentModel); $this->Comment = $this->CommentModel->getID($commentID); } else { + $this->setData('ActionType', 'NewComment'); $this->Form->setModel($this->DraftModel); $this->Comment = $this->DraftModel->getID($draftID); } diff --git a/vanilla/applications/vanilla/models/class.categorymodel.php b/vanilla/applications/vanilla/models/class.categorymodel.php index 7bffb98..e376b1b 100644 --- a/vanilla/applications/vanilla/models/class.categorymodel.php +++ b/vanilla/applications/vanilla/models/class.categorymodel.php @@ -211,6 +211,8 @@ private function calculateUser(array &$category, $addUserCategory = null) { $category['PermsDiscussionsAdd'] = self::checkPermission($category, 'Vanilla.Discussions.Add'); $category['PermsDiscussionsEdit'] = self::checkPermission($category, 'Vanilla.Discussions.Edit'); $category['PermsCommentsAdd'] = self::checkPermission($category, 'Vanilla.Comments.Add'); + $category['PermsDiscussionsUploads'] = self::checkPermission($category, 'Vanilla.Discussions.Uploads'); + $category['PermsCommentsUploads'] = self::checkPermission($category, 'Vanilla.Comments.Uploads'); $code = $category['UrlCode']; $category['Name'] = Gdn::translate("Categories.".$code.".Name", $category['Name']); diff --git a/vanilla/library/core/class.form.php b/vanilla/library/core/class.form.php new file mode 100644 index 0000000..bf7e3c3 --- /dev/null +++ b/vanilla/library/core/class.form.php @@ -0,0 +1,3239 @@ + + * @author Lincoln Russell + * @copyright 2009-2019 Vanilla Forums Inc. + * @license GPL-2.0-only + * @package Core + * @since 2.0 + */ + +/** + * Form validation layer + * + * Helps with the rendering of form controls that link directly to a data model. + */ +class Gdn_Form extends Gdn_Pluggable { + /** + * @var array + */ + private $styles = []; + + /** + * @var array All of the available styles. + */ + private $allStyles = [ + 'legacy' => [ + 'bodybox' => 'TextBox BodyBox js-bodybox', + 'button' => 'Button', + 'button-element' => 'input', + 'checkbox' => 'CheckBoxLabel', + 'dropdown' => '', + 'file' => '', + 'radio' => 'RadioLabel', + 'textarea' => 'TextBox', + 'textbox' => 'InputBox', + 'input-wrap' => 'TextBoxWrapper', + 'form-group' => '', + 'form-footer' => 'Buttons' + ], + 'bootstrap' => [ + 'default' => 'form-control', + 'bodybox' => 'form-control js-bodybox', + 'button' => 'btn btn-primary', + 'button-element' => 'button', + 'checkbox' => '', + 'checkbox-container' => 'checkbox', + 'checkbox-inline' => 'checkbox-inline', + 'file' => 'form-control-file', + 'inputbox' => 'form-control', + 'textbox' => 'form-control', + 'popup' => 'js-popup', + 'primary' => 'btn-primary', + 'radio' => '', + 'radio-container' => 'radio', + 'smallbutton' => 'btn btn-sm', + 'textarea' => 'form-control', + 'dropdown' => 'form-control', + 'input-wrap' => 'input-wrap', + 'form-group' => 'form-group', + 'form-footer' => 'js-modal-footer form-footer' + ] + ]; + + /** @var string Action with which the form should be sent. */ + public $Action = ''; + + /** @var string Class name to assign to form elements with errors when InlineErrors is enabled. */ + public $ErrorClass = 'Error'; + + /** @var array Associative array of hidden inputs with their "Name" attribute as the key. */ + public $HiddenInputs; + + /** + * @var string All form-related elements (form, input, select, textarea, [etc] will have + * this value prefixed on their ID attribute. Default is "Form_". If the + * id value is overridden with the Attribute collection for an element, this + * value will not be used. + */ + public $IDPrefix = 'Form_'; + + /** @var string Form submit method. Options are 'post' or 'get'. */ + public $Method = 'post'; + + /** + * @var array Associative array containing the key => value pairs being placed in the + * controls returned by this object. Assigned by $this->open() or $this->setData(). + */ + protected $_DataArray; + + /** @var bool Whether to display inline errors with form elements. Set with showErrors() and hideErrors(). */ + protected $_InlineErrors = false; + + /** @var object Model that enforces data rules on $this->_DataArray. */ + protected $_Model; + + /** + * @var array Associative array of $FieldName => $ValidationFunctionName arrays that + * describe how each field specified failed validation. + */ + protected $_ValidationResults = []; + + /** + * @var array $Field => $Value pairs from the form in the $_POST or $_GET collection + * (depending on which method was specified for sending form data in $this->Method). + * Populated & accessed by $this->formValues(). + * Values can be retrieved with $this->getFormValue($FieldName). + */ + public $_FormValues; + + /** + * @var array Collection of IDs that have been created for form elements. This + * private property is used to record all IDs so that duplicate IDs are not + * added to the screen. + */ + private $_IDCollection = []; + + /** + * @var array An array of ID counters so that we don't have ID clashes. + */ + private static $idCounters = []; + + /** + * Constructor + * + * @param string $tableName + * @param string $style The style key to use. + */ + public function __construct($tableName = '', $style = '') { + if ($tableName != '') { + $tableModel = new Gdn_Model($tableName); + $this->setModel($tableModel); + } + + if ($style === '') { + $themeInfo = Gdn::themeManager()->getThemeInfo(Gdn::themeManager()->currentTheme()); + $style = val('ControlStyle', $themeInfo); + } + + $this->setStyles($style); + + // Get custom error class + $this->ErrorClass = c('Garden.Forms.InlineErrorClass', 'Error'); + + parent::__construct(); + } + + /** + * Backwards compatibility getter. + * + * @param strig $name The property to get. + * @return mixed Returns the value of the property. + */ + public function __get($name) { + if ($name === 'InputPrefix') { + trigger_error("Gdn_Form->InputPrefix is deprecated", E_USER_DEPRECATED); + } + return null; + } + + /** + * Backwards compatibility setter. + * + * @param string $name The name of the property to set. + * @param mixed $value The new value of the property. + */ + public function __set($name, $value) { + if ($name === 'InputPrefix') { + trigger_error("Gdn_Form->InputPrefix is deprecated", E_USER_DEPRECATED); + } + $this->$name = $value; + } + + + /// ========================================================================= + /// UI Components: Methods that return XHTML form elements. + /// ========================================================================= + + /** + * Add ErrorClass to Attributes['class']. + * + * @since 2.0.18 + * @access public + * + * @param array $attributes Field attributes passed by reference (property => value). + */ + public function addErrorClass(&$attributes) { + if (isset($attributes['class'])) { + $attributes['class'] .= ' '.$this->ErrorClass; + } else { + $attributes['class'] = $this->ErrorClass; + } + } + + + /** + * Set the styles to use when outputting controls. + * + * @param string $name The name of the style. Currently this should be **legacy** or **bootstrap**. + * @return bool Returns **true** if the styles were set or **false** otherwise. + */ + public function setStyles($name) { + if (isset($this->allStyles[$name])) { + $this->styles = $this->allStyles[$name]; + return true; + } else { + $this->styles = $this->allStyles['legacy']; + return false; + } + } + + /** + * Get a style element for the form. + * + * @param string $item The item, such as the element name or whatnot. + * @param null $default The default. If this isn't supplied then the "default" class will be returned. + * @return string Returns the element. + */ + public function getStyle($item, $default = null) { + $item = strtolower($item); + + if (isset($this->styles[$item])) { + return $this->styles[$item]; + } elseif ($default !== null) { + return $default; + } elseif (isset($this->styles['default'])) { + return $this->styles['default']; + } else { + return ''; + } + } + + /** + * Translate old CSS classes using the style array. + * + * @param string|string[] $classes The classes to translate. + * @return string Returns the translated class string. + */ + private function translateClasses($classes) { + if (is_string($classes)) { + $parts = explode(' ', trim($classes)); + } elseif (is_array($classes)) { + $parts = $classes; + } else { + return ''; + } + $classes = []; + foreach ($parts as $part) { + if (!empty($part)) { + $classes[] = $this->getStyle($part, $part); + } + } + + return implode(' ', $classes); + } + + /** + * A special text box for formattable text. + * + * Formatting plugins like ButtonBar will auto-attach to this element. + * + * @param string $column + * @param array $attributes + * @since 2.1 + * @return string HTML element. + */ + public function bodyBox($column = 'Body', $attributes = []) { + touchValue('MultiLine', $attributes, true); + touchValue('Wrap', $attributes, true); + touchValue('class', $attributes, ''); + $attributes['class'] .= ' '.$this->getStyle('bodybox'); + + $this->setValue('Format', val('Format', $attributes, $this->getValue('Format', Gdn_Format::defaultFormat()))); + + $result = '
'; + + // BeforeBodyBox + $this->EventArguments['Table'] = val('Table', $attributes); + $this->EventArguments['Column'] = $column; + $this->EventArguments['Attributes'] = $attributes; + $this->EventArguments['BodyBox'] =& $result; + $this->fireEvent('BeforeBodyBox'); + + // Only add the format if it was set on the form. This allows plugins to remove the format. + if ($format = $this->getValue('Format')) { + $attributes['format'] = htmlspecialchars($format); + $this->setValue('Format', $attributes['format']); + $result .= $this->hidden('Format'); + } + + $result .= $this->textBox($column, $attributes); + + $result .= '
'; + + return $result; + } + + /** + * Returns XHTML for a button. + * + * @param string $buttonCode The translation code for the text on the button. + * @param array $attributes An associative array of attributes for the button. Here is a list of + * "special" attributes and their default values: + * Attribute Options Default + * ------------------------------------------------------------------------ + * Type The type of submit button 'submit' + * Value Ignored for $buttonCode $buttonCode translated + * + * @return string + */ + public function button($buttonCode, $attributes = []) { + $type = arrayValueI('type', $attributes); + if ($type === false) { + $type = 'submit'; + } + + $cssClass = arrayValueI('class', $attributes); + if ($cssClass === false) { + $attributes['class'] = $this->getStyle('button'); + } else { + $attributes['class'] = $this->translateClasses($attributes['class']); + } + + $elem = $this->getStyle('button-element'); + + $return = "<$elem type=\"$type\""; + $return .= $this->_idAttribute($buttonCode, $attributes); + $return .= $this->_nameAttribute($buttonCode, $attributes); + $return .= $this->_attributesToString($attributes); + + if ($elem === 'button') { + $return .= ' value="'.val('value', $attributes, $buttonCode).'">'.htmlspecialchars(t($buttonCode, val('value', $attributes))).''; + } else { + $return .= ' value="'.t($buttonCode, val('value', $attributes)).'"'; + $return .= " />\n"; + } + return $return; + } + + /** + * Return a linked that will look like a button. + * + * @param string $code The text of the anchor. + * @param string $destination The URL path of the anchor. + * @param array $attributes Additional attributes for the anchor. + * @see anchor() + */ + public function linkButton($code, $destination = '', $attributes = []) { + if (empty($attributes['class'])) { + $cssClass = $this->getStyle('button', ''); + } else { + $cssClass = $this->translateClasses($attributes['class']); + unset($attributes['class']); + } + + $result = anchor(t($code), $destination, $cssClass, $attributes, true); + return $result; + } + + /** + * Builds a color-picker form element. Accepts three-character hex values with or without the leading '#', + * but the saved value will be coerced into a six-character hex code with the leading '#'. Also accepts + * 'transparent', 'initial' or 'inherit'. Can be configured to accept an empty string if $options['AllowEmpty'] + * is set to true. The hex value to be saved is the value of the input with the color-picker-value class. + * + * @param string $fieldName Name of the field being posted with this input. + * @param array $options An array of options with the following keys: + * 'AllowEmpty' (bool) Whether to accept empty values for the color picker, defaults to false + * 'Value' (string) Hex color code for the color picker to start with, defaults to "#ffffff" + * @return string The form element for a color picker. + */ + public function color($fieldName, $options = []) { + Gdn::controller()->addJsFile('colorpicker.js', 'dashboard'); + + $valueAttributes['class'] = 'js-color-picker-value color-picker-value Hidden'; + $textAttributes['class'] = 'js-color-picker-text color-picker-text'; + $colorAttributes['class'] = 'js-color-picker-color color-picker-color'; + + if (isset($options['Value'])) { + $valueAttributes['value'] = $options['Value']; + $colorAttributes['value'] = $options['Value']; + } else { + // Default dummy starting color for color input. Color inputs require one, Chrome + // will throw a warning if one doesn't exist. The javascript will ignore this. + $colorAttributes['value'] = '#ffffff'; + } + + $cssClass = 'js-color-picker color-picker input-group'; + + $allowEmpty = $options['AllowEmpty'] ?? false; + $dataAttribute = $allowEmpty ? ' data-allow-empty="true"' : ' data-allow-empty="false"'; + + return '
' + .$this->input($fieldName, 'text', $valueAttributes) + .$this->input($fieldName.'-text', 'text', $textAttributes) + .'' + .$this->input($fieldName.'-color', 'color', $colorAttributes) + .'
'; + } + + /** + * Returns XHTML for a standard calendar input control. + * + * @param string $fieldName The name of the field that is being displayed/posted with this input. It + * should related directly to a field name in $this->_DataArray. + * @param array $attributes An associative array of attributes for the input. ie. onclick, class, etc + * @return string + * @todo Create calendar helper + */ + public function calendar($fieldName, $attributes = []) { + // TODO: CREATE A CALENDAR HELPER CLASS AND LOAD/REFERENCE IT HERE. + // THE CLASS SHOULD BE DECLARED WITH: + // if (!class_exists('Calendar') { + // AT THE BEGINNING SO OTHERS CAN OVERRIDE THE DEFAULT CALENDAR WITH ONE + // OF THEIR OWN. + $class = arrayValueI( + 'class', + $attributes, + false + ); + if ($class === false) { + $attributes['class'] = 'DateBox'; + } + + // IN THE MEANTIME... + return $this->input($fieldName, 'text', $attributes); + } + + /** + * Returns Captcha HTML & adds translations to document head. + * + * Events: BeforeCaptcha + * + * @return string + */ + public function captcha() { + $this->fireEvent('Captcha'); + } + + /** + * Returns XHTML for a select list containing categories that the user has + * permission to use. + * + * @param array $fieldName An array of category data to render. + * @param array $options An associative array of options for the select. Here + * is a list of "special" options and their default values: + * + * Attribute Options Default + * ------------------------------------------------------------------------ + * Value The ID of the category that FALSE + * is selected. + * IncludeNull Include a blank row? TRUE + * Context A set of categories to [] + * interset with the CategoryData + * that is relative to the category + * we're in. + * CategoryData Custom set of categories to CategoryModel::categories() + * display. + * Headings Whether or not do display headings. + * EnableHeadings Whether or not headings should be enabled for selection. + * + * @return string + */ + public function categoryDropDown($fieldName = 'CategoryID', $options = []) { + + $this->EventArguments['Options'] = &$options; + $this->fireEvent('BeforeCategoryDropDown'); + + $value = arrayValueI('Value', $options); // The selected category id + $categoryData = val('CategoryData', $options); + + if (!$categoryData && val('Context', $options)) { + $categoryData = val('Context', $options); + } elseif ($categoryData && val('Context', $options)) { + $categoryData = array_intersect_key($categoryData, val('Context', $options)); + } + + // Sanity check + if (is_object($categoryData)) { + $categoryData = (array)$categoryData; + } elseif (!is_array($categoryData)) { + $categoryData = []; + } + + $permission = val('Permission', $options, 'add'); + + // Grab the category data. + if (!$categoryData) { + $categoryData = CategoryModel::getByPermission( + 'Discussions.View', + $value, + val('Filter', $options, ['Archived' => 0]), + val('PermFilter', $options, []) + ); + } + + // Remove categories the user shouldn't see. + $safeCategoryData = []; + $discussionType = val('DiscussionType', $options); + foreach ($categoryData as $categoryID => $category) { + if ($value != $categoryID) { + if ($category['CategoryID'] <= 0 || !$category['PermsDiscussionsView']) { + continue; + } + + if ($category['Archived']) { + continue; + } + + // Filter out categories that don't allow our discussion type, if specified + if ($discussionType) { + $permissionCategory = CategoryModel::permissionCategory($category); + $allowedDiscussionTypes = CategoryModel::allowedDiscussionTypes($permissionCategory, $category); + if (!array_key_exists($discussionType, $allowedDiscussionTypes)) { + continue; + } + } + } + + $safeCategoryData[$categoryID] = $category; + } + unset($discussionType, $permissionCategory, $allowedDiscussionTypes); + + unset($options['Filter'], $options['PermFilter'], $options['Context'], $options['CategoryData']); + + if (!isset($options['class'])) { + $options['class'] = $this->getStyle('dropdown'); + } + + // Opening select tag + $return = '_idAttribute($fieldName, $options); + $return .= $this->_nameAttribute($fieldName, $options); + $return .= $this->_attributesToString($options); + $return .= ">\n"; + + // Get value from attributes + if ($value === false) { + $value = $this->getValue($fieldName); + } + if (!is_array($value)) { + $value = [$value]; + } + + // Prevent default $Value from matching key of zero + $hasValue = ($value !== [false] && $value !== ['']) ? true : false; + + // Start with null option? + $includeNull = val('IncludeNull', $options); + if ($includeNull === true) { + $return .= ''; + } elseif (is_array($includeNull)) + $return .= "\n"; + elseif ($includeNull) + $return .= "\n"; + elseif (!$hasValue) + $return .= ''; + + // Show root categories as headings (ie. you can't post in them)? + $doHeadings = val('Headings', $options, c('Vanilla.Categories.DoHeadings')); + + // If making headings disabled and there was no default value for + // selection, make sure to select the first non-disabled value, or the + // browser will auto-select the first disabled option. + $forceCleanSelection = ($doHeadings && !$hasValue && !$includeNull); + + // Write out the category options. + $enableHeadings = $options['EnableHeadings'] ?? false; + if (is_array($safeCategoryData)) { + $canUpload = Gdn::session()->checkPermission('Garden.Uploads.Add'); + foreach ($safeCategoryData as $categoryID => $category) { + $depth = val('Depth', $category, 0); + $isHeading = ($depth == 1 && $doHeadings) || $category['DisplayAs'] !== 'Discussions' || !$category['AllowDiscussions']; + $disabled = $isHeading && !$enableHeadings; + $selected = in_array($categoryID, $value) && $hasValue; + if ($forceCleanSelection && $depth > 1) { + $selected = true; + $forceCleanSelection = false; + } + + if ($category['AllowDiscussions']) { + if ($permission == 'add' && !$category['PermsDiscussionsAdd']) { + $disabled = true; + } + } + + // FIX: https://github.com/topcoder-platform/forums/issues/373 + $discussionUploads = val('PermsDiscussionsUploads',$category, $canUpload); + + $return .= '\n"; + } + } + return $return.''; + } + + /** + * Outputs a checkbox painted as a toggle. Includes label wrap id a label is given. + * + * @param string $fieldName The key name for the field. + * @param string $label The label for the field. + * @param array $attributes The attributes for the checkbox input. + * @param string $info The label description. + * @param bool $reverse Whether to reverse the representation of the toggle (positive value is on, neg value is off). + * @return string And HTML-formatted form field for a toggle. + */ + public function toggle($fieldName, $label = '', $attributes = [], $info = '', $reverse = false) { + $value = arrayValueI('value', $attributes, true); + $attributes['value'] = $value; + if (stringEndsWith($fieldName, '[]')) { + if (!isset($attributes['checked'])) { + $getValue = $this->getValue(substr($fieldName, 0, -2)); + if (is_array($getValue) && in_array($value, $getValue)) { + $attributes['checked'] = 'checked'; + } elseif ($getValue == $value) + $attributes['checked'] = 'checked'; + } + } else { + if ($this->getValue($fieldName) == $value) { + $attributes['checked'] = 'checked'; + } + } + + if ($reverse) { + if ($attributes['checked'] === 'checked') { + unset($attributes['checked']); + } else { + $attributes['checked'] = 'checked'; + } + } + + $id = arrayValueI('id', $attributes, $this->escapeID($fieldName, false)); + + $attributes['aria-labelledby'] = 'label-'.$id; + $attributes['class'] = 'toggle-input'; + $input = $this->input($fieldName, 'checkbox', $attributes); + $toggleLabel = ' '.$input; + } elseif ($display === 'toggle') { + $input = '
'.$input.$labelElement.'
'; + } else { + $input = $input.' '.$labelElement.t($label).''; + } + } + + // Append validation error message + if ($showErrors && arrayValueI('InlineErrors', $attributes, true)) { + $input .= $this->inlineError($fieldName); + } + + if ($this->getStyle('checkbox-container', '') && stripos($class, 'inline') == false) { + $container = $this->getStyle('checkbox-container'); + $input = "
".$input.'
'; + } + + return $input; + } + + /** + * Returns the XHTML for a list of checkboxes. + * + * @param string $fieldName Name of the field being posted with this input. + * + * @param mixed $dataSet Data to fill the checkbox list. Either an associative + * array or a database dataset. ex: RoleID, Name from GDN_Role. + * + * @param mixed $valueDataSet Values to be pre-checked in $dataSet. Either an associative array + * or a database dataset. ex: RoleID from GDN_UserRole for a single user. + * + * @param array $attributes An associative array of attributes for the select. Here is a list of + * "special" attributes and their default values: + * Attribute Options Default + * ------------------------------------------------------------------------ + * ValueField The name of the field in 'value' + * $dataSet that contains the + * option values. + * TextField The name of the field in 'text' + * $dataSet that contains the + * option text. + * + * @return string + */ + public function checkBoxList($fieldName, $dataSet, $valueDataSet = null, $attributes = []) { + // Never display individual inline errors for these CheckBoxes + $attributes['InlineErrors'] = false; + + $return = ''; + // If the form hasn't been posted back, use the provided $ValueDataSet + if ($this->isPostBack() === false) { + if ($valueDataSet === null) { + $checkedValues = $this->getValue($fieldName); + } else { + $checkedValues = $valueDataSet; + if (is_object($valueDataSet)) { + $checkedValues = array_column($valueDataSet->resultArray(), $fieldName); + } + } + } else { + $checkedValues = $this->getFormValue($fieldName, []); + } + $i = 1; + if (is_object($dataSet)) { + $valueField = arrayValueI('ValueField', $attributes, 'value'); + $textField = arrayValueI('TextField', $attributes, 'text'); + foreach ($dataSet->result() as $data) { + $instance = $attributes; + unset($instance['TextField'], $instance['ValueField']); + $instance['value'] = $data->$valueField; + $instance['id'] = $fieldName.$i; + if (is_array($checkedValues) && in_array( + $data->$valueField, + $checkedValues + ) + ) { + $instance['checked'] = 'checked'; + } + + $return .= '
  • '.$this->checkBox( + $fieldName.'[]', + $data->$textField, + $instance + )."
  • \n"; + ++$i; + } + } elseif (is_array($dataSet)) { + foreach ($dataSet as $text => $iD) { + // Set attributes for this instance + $instance = $attributes; + unset($instance['TextField'], $instance['ValueField']); + + $instance['id'] = $fieldName.$i; + + if (is_array($iD)) { + $valueField = arrayValueI('ValueField', $attributes, 'value'); + $textField = arrayValueI('TextField', $attributes, 'text'); + $text = val($textField, $iD, ''); + $iD = val($valueField, $iD, ''); + } else { + if (is_numeric($text)) { + $text = $iD; + } + } + $instance['value'] = $iD; + + if (is_array($checkedValues) && in_array($iD, $checkedValues)) { + $instance['checked'] = 'checked'; + } + + $return .= '
  • '.$this->checkBox($fieldName.'[]', $text, $instance)."
  • \n"; + ++$i; + } + } + + return '
      '.$return.'
    '; + } + + /** + * Returns the xhtml for a list of checkboxes; sorted into groups related to + * the TextField value of the dataset. + * + * @param string $fieldName The name of the field that is being displayed/posted with this input. It + * should related directly to a field name in a user junction table. + * ie. LUM_UserRole.RoleID + * + * @param mixed $dataSet The data to fill the options in the select list. Either an associative + * array or a database dataset. ie. RoleID, Name from LUM_Role. + * + * @param mixed $valueDataSet The data that should be checked in $dataSet. Either an associative array + * or a database dataset. ie. RoleID from LUM_UserRole for a single user. + * + * @param array $attributes An associative array of attributes for the select. Here is a list of + * "special" attributes and their default values: + * + * Attribute Options Default + * ------------------------------------------------------------------------ + * ValueField The name of the field in 'value' + * $dataSet that contains the + * option values. + * TextField The name of the field in 'text' + * $dataSet that contains the + * option text. + * + * @return string + */ + public function checkBoxGrid($fieldName, $dataSet, $valueDataSet, $attributes) { + // Never display individual inline errors for these CheckBoxes + $attributes['InlineErrors'] = false; + + $return = ''; + $checkedValues = $valueDataSet; + if (is_object($valueDataSet)) { + $checkedValues = array_column($valueDataSet->resultArray(), $fieldName); + } + + $i = 1; + if (is_object($dataSet)) { + $valueField = arrayValueI('ValueField', $attributes, 'value'); + $textField = arrayValueI('TextField', $attributes, 'text'); + $lastGroup = ''; + $group = []; + $rows = []; + $cols = []; + $checkBox = ''; + foreach ($dataSet->result() as $data) { + // Define the checkbox + $instance = $attributes; + unset($instance['TextField'], $instance['ValueField']); + $instance['value'] = $data->$valueField; + $instance['id'] = $fieldName.$i; + if (is_array($checkedValues) && in_array( + $data->$valueField, + $checkedValues + ) + ) { + $instance['checked'] = 'checked'; + } + $checkBox = $this->checkBox($fieldName.'[]', '', $instance); + + // Organize the checkbox into an array for this group + $currentTextField = $data->$textField; + $aCurrentTextField = explode('.', $currentTextField); + $aCurrentTextFieldCount = count($aCurrentTextField); + $groupName = array_shift($aCurrentTextField); + $colName = array_pop($aCurrentTextField); + if ($aCurrentTextFieldCount >= 3) { + $rowName = implode('.', $aCurrentTextField); + if ($groupName != $lastGroup && $lastGroup != '') { + // Render the last group + $return .= $this->getCheckBoxGridGroup( + $lastGroup, + $group, + $rows, + $cols + ); + + // Clean out the $Group array & Rowcount + $group = []; + $rows = []; + $cols = []; + } + + if (array_key_exists($colName, $group) === false || is_array($group[$colName]) === false) { + $group[$colName] = []; + if (!in_array($colName, $cols)) { + $cols[] = $colName; + } + + } + + if (!in_array($rowName, $rows)) { + $rows[] = $rowName; + } + + $group[$colName][$rowName] = $checkBox; + $lastGroup = $groupName; + } + ++$i; + } + } + return $return.$this->getCheckBoxGridGroup($lastGroup, $group, $rows, $cols); + } + + /** + * + * + * @param $data + * @param $fieldName + * @return string + */ + public function checkBoxGridGroups($data, $fieldName) { + $result = ''; + foreach ($data as $groupName => $groupData) { + $result .= $this->checkBoxGridGroup($groupName, $groupData, $fieldName)."\n"; + } + return $result; + } + + /** + * + * + * @param $groupName + * @param $data + * @param $fieldName + * @return string + */ + public function checkBoxGridGroup($groupName, $data, $fieldName) { + // Never display individual inline errors for these CheckBoxes + $attributes['InlineErrors'] = false; + + // Get the column and row info. + $columns = $data['_Columns']; + ksort($columns); + $rows = $data['_Rows']; + ksort($rows); + unset($data['_Columns'], $data['_Rows']); + + if (array_key_exists('_Info', $data)) { + $groupName = $data['_Info']['Name']; + unset($data['_Info']); + } + + $result = '
    '; + // Append the header. + $result .= ''; + foreach ($columns as $columnName => $x) { + $result .= + ''; + } + $result.''; + + // Append the rows. + $result .= ''; + $checkCount = 0; + foreach ($rows as $rowName => $x) { + $result .= ''; + // Append the columns within the rows. + foreach ($columns as $columnName => $y) { + $result .= ''; + } + $result .= ''; + } + $result .= '
    '.t($groupName).'' + .t($columnName) + .'
    '; + + // If the row name is still seperated by dots then put those in spans. + $rowNames = explode('.', $rowName); + for ($i = 0; $i < count($rowNames) - 1; ++$i) { + $result .= ''.t($rowNames[$i]).''; + } + $result .= t(self::labelCode($rowNames[count($rowNames) - 1])).''; + // Check to see if there is a row corresponding to this area. + if (array_key_exists($rowName.'.'.$columnName, $data)) { + $checkBox = $data[$rowName.'.'.$columnName]; + $attributes = [ + 'value' => $checkBox['PostValue'], + 'display' => 'after' + ]; + if ($checkBox['Value']) { + $attributes['checked'] = 'checked'; + } +// $Attributes['id'] = "{$GroupName}_{$FieldName}_{$CheckCount}"; + $checkCount++; + + $result .= wrap( + $this->checkBox($fieldName.'[]', $rowName.'.'.$columnName, $attributes), + 'div', + ['class' => 'checkbox-painted-wrapper'] + ); + } else { + $result .= ' '; + } + $result .= '
    '; + return $result; + } + + /** + * Returns the closing of the form tag with an optional submit button. + * + * @param string $buttonCode + * @param string $xhtml + * @return string + */ + public function close($buttonCode = '', $xhtml = '', $attributes = []) { + $return = "\n"; + + if ($xhtml != '') { + $return = $xhtml.$return; + } + + $formFooter = val('FormFooter', $attributes, false); + + if ($formFooter) { + unset($attributes['FormFooter']); + } + + if ($buttonCode != '') { + $buttonCode = $this->button($buttonCode, $attributes); + } + + if ($formFooter || $buttonCode) { + $return = '
    '.$formFooter.$buttonCode.'
    '.$return; + } + + return $return; + } + + /** + * Returns the current image in a field. + * This is meant to be used with image uploads so that users can see the current value. + * + * @param string $fieldName + * @param array $attributes + * @return string + */ + public function currentImage($fieldName, $attributes = []) { + $result = $this->hidden($fieldName); + + $value = $this->getValue($fieldName); + if ($value) { + touchValue('class', $attributes, 'CurrentImage'); + $result .= img(Gdn_Upload::url($value), $attributes); + } + + return $result; + } + + /** + * Returns XHTML for a standard date input control. + * + * @param string $fieldName The name of the field that is being displayed/posted with this input. It + * should related directly to a field name in $this->_DataArray. + * @param array $attributes An associative array of attributes for the input, e.g. onclick, class. + * Special attributes: + * YearRange, specified in yyyy-yyyy format. Default is 1900 to current year. + * Fields, array of month, day, year. Those are only valid values. Order matters. + * @return string + */ + public function date($fieldName, $attributes = []) { + $return = ''; + $yearRange = arrayValueI('yearrange', $attributes, false); + $startYear = 0; + $endYear = 0; + if ($yearRange !== false) { + if (preg_match("/^[\d]{4}-{1}[\d]{4}$/i", $yearRange) == 1) { + $startYear = substr($yearRange, 0, 4); + $endYear = substr($yearRange, 5); + } + } + if ($yearRange === false) { + $startYear = date('Y'); + $endYear = 1900; + } + + $months = array_map( + 't', + explode(',', 'Month,Jan,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct,Nov,Dec') + ); + + $days = [t('Day')]; + for ($i = 1; $i < 32; ++$i) { + $days[] = $i; + } + + $years = [t('Year')]; + foreach (range($startYear, $endYear) as $year) { + $years[$year] = $year; + } + + // Show inline errors? + $showErrors = $this->_InlineErrors && array_key_exists($fieldName, $this->_ValidationResults); + + // Add error class to input element + if ($showErrors) { + $this->addErrorClass($attributes); + } + + // Never display individual inline errors for these DropDowns + $attributes['InlineErrors'] = false; + + $cssClass = arrayValueI('class', $attributes, ''); + + if ($this->getValue($fieldName) > 0) { + $submittedTimestamp = strtotime($this->getValue($fieldName)); + } else { + $submittedTimestamp = false; + } + + // Allow us to specify which fields to show & order + $fields = arrayValueI('fields', $attributes, ['month', 'day', 'year']); + if (is_array($fields)) { + foreach ($fields as $field) { + switch ($field) { + case 'month': + // Month select + $attributes['class'] = trim($cssClass.' Month'); + if ($submittedTimestamp) { + $attributes['Value'] = date('n', $submittedTimestamp); + } + $return .= $this->dropDown($fieldName.'_Month', $months, $attributes); + break; + case 'day': + // Day select + $attributes['class'] = trim($cssClass.' Day'); + if ($submittedTimestamp) { + $attributes['Value'] = date('j', $submittedTimestamp); + } + $return .= $this->dropDown($fieldName.'_Day', $days, $attributes); + break; + case 'year': + // Year select + $attributes['class'] = trim($cssClass.' Year'); + if ($submittedTimestamp) { + $attributes['Value'] = date('Y', $submittedTimestamp); + } + $return .= $this->dropDown($fieldName.'_Year', $years, $attributes); + break; + } + } + } + + $return .= ''; + + // Append validation error message + if ($showErrors) { + $return .= $this->inlineError($fieldName); + } + + return $return; + } + + /** + * Returns XHTML for a select list. + * + * @param string $fieldName The name of the field that is being displayed/posted with this input. It + * should related directly to a field name in $this->_DataArray. ie. RoleID + * @param mixed $dataSet The data to fill the options in the select list. Either an associative + * array or a database dataset. + * @param array $attributes An associative array of attributes for the select. Here is a list of + * "special" attributes and their default values: + * + * Attribute Options Default + * ------------------------------------------------------------------------ + * ValueField The name of the field in 'value' + * $dataSet that contains the + * option values. + * TextField The name of the field in 'text' + * $dataSet that contains the + * option text. + * Value A string or array of strings. $this->_DataArray->$fieldName + * IncludeNull TRUE to include a blank row FALSE + * String to create disabled + * first option. + * InlineErrors Show inline error message? TRUE + * Allows disabling per-dropdown + * for multi-fields like date() + * + * @return string + */ + public function dropDown($fieldName, $dataSet, $attributes = []) { + // Show inline errors? + $showErrors = ($this->_InlineErrors && array_key_exists($fieldName, $this->_ValidationResults)); + + // Add error class to input element + if ($showErrors) { + $this->addErrorClass($attributes); + } + + if (!isset($attributes['class'])) { + $attributes['class'] = $this->getStyle('dropdown'); + } else { + $attributes['class'] = $this->translateClasses($attributes['class']); + } + + $return = ''; + + $wrap = val('Wrap', $attributes, false); + if ($wrap) { + $return = '
    '; + } + + // Opening select tag + $return .= '_idAttribute($fieldName, $attributes); + $return .= $this->_nameAttribute($fieldName, $attributes); + $return .= $this->_attributesToString($attributes); + $return .= ">\n"; + + // Get value from attributes and ensure it's an array + $value = arrayValueI('Value', $attributes); + if ($value === false) { + $value = $this->getValue($fieldName, val('Default', $attributes)); + } + if (!is_array($value)) { + $value = [$value]; + } + + // Prevent default $Value from matching key of zero + $hasValue = ($value !== [false] && $value !== ['']) ? true : false; + + // Start with null option? + $includeNull = arrayValueI('IncludeNull', $attributes, false); + if ($includeNull === true) { + $return .= "\n"; + } elseif ($includeNull) + $return .= "\n"; + + if (is_object($dataSet)) { + $fieldsExist = false; + $valueField = arrayValueI('ValueField', $attributes, 'value'); + $textField = arrayValueI('TextField', $attributes, 'text'); + $data = $dataSet->firstRow(); + if (is_object($data) && property_exists($data, $valueField) && property_exists( + $data, + $textField + ) + ) { + foreach ($dataSet->result() as $data) { + $return .= '\n"; + } + } + } elseif (is_array($dataSet)) { + foreach ($dataSet as $iD => $text) { + if (is_array($text)) { + $attribs = $text; + $text = val('Text', $attribs, ''); + unset($attribs['Text']); + } else { + $attribs = []; + } + $return .= '\n"; + } + } + $return .= ''; + + if ($wrap) { + $return .= '
    '; + } + + // Append validation error message + if ($showErrors && arrayValueI('InlineErrors', $attributes, true)) { + $return .= $this->inlineError($fieldName); + } + + return $return; + } + + /** + * Returns the xhtml for a dropdown list with option groups. + * @param string $fieldName + * @param array $data + * @param string $groupField + * @param string $textField + * @param string $valueField + * @param array $attributes + * @return string + */ + public function dropDownGroup($fieldName, $data, $groupField, $textField, $valueField, $attributes = []) { + $return = '_idAttribute($fieldName, $attributes) + .$this->_nameAttribute($fieldName, $attributes) + .$this->_attributesToString($attributes) + .">\n"; + + // Get the current value. + $currentValue = val('Value', $attributes, false); + if ($currentValue === false) { + $currentValue = $this->getValue($fieldName, getValue('Default', $attributes)); + } + + // Add a null option? + $includeNull = arrayValueI('IncludeNull', $attributes, false); + if ($includeNull === true) { + $return .= "\n"; + } elseif ($includeNull) + $return .= "\n"; + + $lastGroup = null; + + foreach ($data as $row) { + $group = $row[$groupField]; + + // Check for a group header. + if ($lastGroup !== $group) { + // Close off the last opt group. + if ($lastGroup !== null) { + $return .= ''; + } + + $return .= '\n"; + $lastGroup = $group; + } + + $value = $row[$valueField]; + + if ($currentValue == $value) { + $selected = ' selected="selected"'; + } else { + $selected = ''; + } + + $return .= '\n"; + + } + + if ($lastGroup) { + $return .= ''; + } + + $return .= ''; + + return $return; + } + + /** + * Returns XHTML for all form-related errors that have occurred. + * + * @return string + */ + public function errors() { + $return = ''; + if (is_array($this->_ValidationResults) && count($this->_ValidationResults) > 0) { + $return = "
    \n
      \n"; + foreach ($this->_ValidationResults as $fieldName => $problems) { + $count = count($problems); + for ($i = 0; $i < $count; ++$i) { + if (substr($problems[$i], 0, 1) == '@') { + $return .= "
    • ".substr($problems[$i], 1)."
    • \n"; + } else { + $return .= '
    • '.sprintf( + t($problems[$i]), + t($fieldName) + )."
    • \n"; + } + } + } + $return .= "
    \n
    \n"; + } + return $return; + } + + public function errorString() { + $return = ''; + if (is_array($this->_ValidationResults) && count($this->_ValidationResults) > 0) { + foreach ($this->_ValidationResults as $fieldName => $problems) { + $count = count($problems); + for ($i = 0; $i < $count; ++$i) { + if (substr($problems[$i], 0, 1) == '@') { + $return .= rtrim(substr($problems[$i], 1), '.').'. '; + } else { + $return .= rtrim(sprintf( + t($problems[$i]), + t($fieldName) + ), '.').'. '; + } + } + } + } + return trim($return); + } + + /** + * @see Gdn_Form::escapeFieldName() + * @deprecated + * + * @param string $string + * @return string + */ + public function escapeString($string) { + deprecated('Gd_Form::escapeString()'); + return $this->escapeFieldName($string); + } + + /** + * Returns a checkbox table. + * + * @param string $groupName The name of the checkbox table (the text that appears in the top-left + * cell of the table). This value will be passed through the t() + * function before render. + * + * @param array $group An array of $PermissionName => $CheckBoxXhtml to be rendered within the + * grid. This represents the final (third) part of the permission name + * string, as in the "Edit" part of "Garden.Roles.Edit". + * ie. 'Edit' => ''; + * + * @param array $rows An array of rows to appear in the grid. This represents the middle part + * of the permission name, as in the "Roles" part of "Garden.Roles.Edit". + * + * @param array $cols An array of columns to appear in the grid for each row. This (again) + * represents the final part of the permission name, as in the "Edit" part + * of "Garden.Roles.Edit". + * ie. Row1 = array('Add', 'Edit', 'Delete'); + */ + public function getCheckBoxGridGroup($groupName, $group, $rows, $cols) { + $return = ''; + $headings = ''; + $cells = ''; + $rowCount = count($rows); + $colCount = count($cols); + for ($j = 0; $j < $rowCount; ++$j) { + $alt = true; + for ($i = 0; $i < $colCount; ++$i) { + $colName = $cols[$i]; + $rowName = $rows[$j]; + + if ($j == 0) { + $headings .= ''.t($colName).''; + } + + if (array_key_exists($rowName, $group[$colName])) { + $cells .= ''.$group[$colName][$rowName]. + ''; + } else { + $cells .= ' '; + } + $alt = !$alt; + } + if ($headings != '') { + $return .= "".t($groupName)."". + $headings."\r\n"; + } + + $aRowName = explode('.', $rowName); + $rowNameCount = count($aRowName); + if ($rowNameCount > 1) { + $rowName = ''; + for ($i = 0; $i < $rowNameCount; ++$i) { + if ($i < $rowNameCount - 1) { + $rowName .= ''. + t($aRowName[$i]).''; + } else { + $rowName .= t($aRowName[$i]); + } + } + } else { + $rowName = t($rowName); + } + $return .= ''.$rowName.''.$cells."\r\n"; + $headings = ''; + $cells = ''; + } + return $return == '' ? '' : '
    '.$return.'
    '; + } + + /** + * Returns XHTML for all hidden fields. + * + * @return string + */ + public function getHidden() { + $return = ''; + if (is_array($this->HiddenInputs)) { + foreach ($this->HiddenInputs as $name => $value) { + $return .= $this->hidden($name, ['value' => $value]); + } + // Clean out the array + // mosullivan - removed cleanout so that entry forms can all have the same hidden inputs added once on the entry/index view. + // TODO - WATCH FOR BUGS BECAUSE OF THIS CHANGE. + // $this->HiddenInputs = array(); + } + return $return; + } + + /** + * Returns the xhtml for a hidden input. + * + * @param string $fieldName The name of the field that is being hidden/posted with this input. It + * should related directly to a field name in $this->_DataArray. + * @param array $attributes An associative array of attributes for the input. ie. maxlength, onclick, + * class, etc + * @return string + */ + public function hidden($fieldName, $attributes = []) { + $return = '_idAttribute($fieldName, $attributes); + $return .= $this->_nameAttribute($fieldName, $attributes); + $return .= $this->_valueAttribute($fieldName, $attributes); + $return .= $this->_attributesToString($attributes); + $return .= ' />'; + return $return; + } + + /** + * Returns the xhtml for a react rendered input component. + * + * @param string $fieldName The name of the field that is being hidden/posted with this input. It + * should related directly to a field name in $this->_DataArray. + * @param string $componentKey The key of the of the component registered in the frontend with addComponent. + * @return string + */ + public function react(string $fieldName, string $componentKey) { + $value = $attributes['value'] ?? $this->getValue($fieldName); + $props = htmlspecialchars(json_encode(['initialValue' => $value])); + + return "
    "; + } + + /** + * Return a control for uploading images. + * + * @param string $fieldName + * @param array $attributes + * @return string + * @since 2.1 + */ + public function imageUpload($fieldName, $attributes = []) { + $result = '
    '. + $this->currentImage($fieldName, $attributes). + '
    '. + $this->input($fieldName.'_New', 'file'). + '
    '. + '
    '; + + return $result; + } + + /** + * Return a control for uploading images with a wrapper div. The existing image should be displayed by the label. + * + * @param string $fieldName + * @param array $attributes + * @return string + */ + public function imageUploadWrap($fieldName, $attributes = []) { + return $this->fileUploadWrap($fieldName.'_New', $attributes); + } + + /** + * Returns XHTML of inline error for specified field. + * + * @since 2.0.18 + * @access public + * + * @param string $fieldName The name of the field that is being displayed/posted with this input. It + * should related directly to a field name in $this->_DataArray. + * @return string + */ + public function inlineError($fieldName) { + $appendError = '

    '; + foreach ($this->_ValidationResults[$fieldName] as $validationError) { + $appendError .= sprintf(t($validationError), t($fieldName)).' '; + } + $appendError .= '

    '; + + return $appendError; + } + + /** + * Returns the xhtml for a standard input tag. + * + * @param string $fieldName The name of the field that is being displayed/posted with this input. It + * should related directly to a field name in $this->_DataArray. + * @param string $type The type attribute for the input. + * @param array $attributes An associative array of attributes for the input. (e.g. maxlength, onclick, class) + * Setting 'InlineErrors' to FALSE prevents error message even if $this->InlineErrors is enabled. + * @return string + */ + public function input($fieldName, $type = 'text', $attributes = []) { + switch ($type) { + case 'checkbox': + case 'button': + case 'hidden': + case 'radio': + case 'reset': + case 'submit': + $typeClass = ''; + break; + case 'file': + $typeClass = 'file'; + break; + default: + $typeClass = 'textbox'; + break; + } + $attributes['class'] = $this->translateClasses(arrayValueI('class', $attributes).' '.$typeClass); + + // Show inline errors? + $showErrors = $this->_InlineErrors && array_key_exists($fieldName, $this->_ValidationResults); + + // Add error class to input element + if ($showErrors) { + $this->addErrorClass($attributes); + } + + $return = ''; + $wrap = val('Wrap', $attributes, false, true); + $strength = val('Strength', $attributes, false, true); + if ($wrap) { + $return .= '
    '; + } + + if (strtolower($type) == 'checkbox') { + if (isset($attributes['nohidden'])) { + unset($attributes['nohidden']); + } else { + $return .= ''; + } + } + + + $return .= '_idAttribute($fieldName, $attributes); + if ($type == 'file') { + $return .= attribute( + 'name', + arrayValueI('Name', $attributes, $fieldName) + ); + } else { + $return .= $this->_nameAttribute($fieldName, $attributes); + if ($strength) { + $return .= ' data-strength="true"'; + } + $return .= $this->_valueAttribute($fieldName, $attributes); + } + + $return .= $this->_attributesToString($attributes); + $return .= ' />'; + + + // Append validation error message + if ($showErrors && arrayValueI('InlineErrors', $attributes, true)) { + $return .= $this->inlineError($fieldName); + } + + if ($type == 'password' && $strength) { + $return .= << +
    +
    +
    +
    +
    +
    +
     
    +
    +PASSWORDMETER; + } + + if ($wrap) { + $return .= ''; + } + + return $return; + } + + + public function inputWrap($fieldName, $type = 'text', $attributes = []) { + return '
    '.$this->input($fieldName, $type, $attributes).'
    '; + } + + /** + * Returns XHTML for a label element. + * + * @param string $translationCode Code to be translated and presented within the label tag. + * @param string $fieldName Name of the field that the label is for. + * @param array $attributes Associative array of attributes for the input that the label is for. + * This is only available in case the related input has a custom id specified in the attributes array. + * + * @return string + */ + public function label($translationCode, $fieldName = '', $attributes = []) { + // Assume we always want a 'for' attribute because it's Good & Proper. + // Precedence: 'for' attribute, 'id' attribute, $FieldName, $TranslationCode + $defaultFor = ($fieldName == '') ? $translationCode : $fieldName; + $for = arrayValueI('for', $attributes, arrayValueI('id', $attributes, $this->escapeID($defaultFor, false))); + + $return = '\n"; + return $return; + } + + public function labelWrap($translationCode, $fieldName = '', $attributes = []) { + return '
    '.$this->label($translationCode, $fieldName, $attributes).'
    '; + } + + /** + * Generate a friendly looking label translation code from a camel case variable name + * @param string|array $item The item to generate the label from. + * - string: Generate the label directly from the item. + * - array: Generate the label from the item as if it is a schema row passed to Gdn_Form::simple(). + * @return string + */ + public static function labelCode($item) { + if (is_array($item)) { + if (isset($item['LabelCode'])) { + return $item['LabelCode']; + } + + $labelCode = $item['Name']; + } else { + $labelCode = $item; + } + + + if (strpos($labelCode, '.') !== false) { + $labelCode = trim(strrchr($labelCode, '.'), '.'); + } + + // Split camel case labels into seperate words. + $labelCode = preg_replace('`(?_idAttribute('', $attributes); + } + + // Method + $methodFromAttributes = arrayValueI('method', $attributes); + $this->Method = $methodFromAttributes === false ? $this->Method : $methodFromAttributes; + + // Action + $actionFromAttributes = arrayValueI('action', $attributes); + if ($this->Action == '') { + $this->Action = url(); + } + + $this->Action = $actionFromAttributes === false ? $this->Action : $actionFromAttributes; + + if (strcasecmp($this->Method, 'get') == 0) { + // The path is not getting passed on get forms so put them in hidden fields. + $action = strrchr($this->Action, '?'); + $exclude = val('Exclude', $attributes, []); + if ($action !== false) { + $this->Action = substr($this->Action, 0, -strlen($action)); + parse_str(trim($action, '?'), $query); + $hiddens = ''; + foreach ($query as $key => $value) { + if (in_array($key, $exclude)) { + continue; + } + $key = Gdn_Format::form($key); + $value = Gdn_Format::form($value); + $hiddens .= "\n"; + } + } + } + + $return .= ' method="'.$this->Method.'"' + .' action="'.$this->Action.'"' + .$this->_attributesToString($attributes) + .">\n
    \n"; + + if (isset($hiddens)) { + $return .= $hiddens; + } + + // Postback Key - don't allow it to be posted in the url (prevents csrf attacks & hijacks) + if ($this->Method != "get") { + $session = Gdn::session(); + $return .= $this->hidden( + 'TransientKey', + ['value' => $session->transientKey()] + ); + // Also add a honeypot if Forms.HoneypotName has been defined + $honeypotName = Gdn::config( + 'Garden.Forms.HoneypotName' + ); + if ($honeypotName) { + $return .= $this->hidden( + $honeypotName, + ['Name' => $honeypotName, 'style' => "display: none;"] + ); + } + } + + // Render all other hidden inputs that have been defined + $return .= $this->getHidden(); + return $return; + } + + /** + * Returns XHTML for a radio input element. + * + * Provides way of wrapping input() with a label. + * + * @param string $fieldName Name of the field that is being displayed/posted with this input. + * It should related directly to a field name in $this->_DataArray. + * @param string $label Label to place next to the radio. + * @param array $attributes Associative array of attributes for the input (e.g. onclick, class). + * Special values 'Value' and 'Default' (see RadioList). + * @return string + */ + public function radio($fieldName, $label = '', $attributes = []) { + $value = arrayValueI('Value', $attributes, 'TRUE'); + $attributes['value'] = $value; + $formValue = $this->getValue($fieldName, arrayValueI('Default', $attributes)); + $display = val('display', $attributes, 'wrap'); + unset($attributes['display']); + + // Check for 'checked' + if ($formValue == $value) { + $attributes['checked'] = 'checked'; + } + + // Never display individual inline errors for this Input + $attributes['InlineErrors'] = false; + + // Get standard radio Input + $input = $this->input($fieldName, 'radio', $attributes); + + if (isset($attributes['class'])) { + $class = $this->translateClasses($attributes['class']); + } else { + $class = $this->getStyle('radio'); + } + + // Wrap with label. + if ($label != '') { + $labelElement = ''; + } elseif ($display === 'before') { + $input = $labelElement.t($label).' '.$input; + } else { + $input = $input.' '.$labelElement.t($label).''; + } + } + + return $input; + } + + /** + * Returns XHTML for an unordered list of radio button elements. + * + * @param string $fieldName The name of the field that is being displayed/posted with this input. + * It should related directly to a field name in $this->_DataArray. ie. RoleID + * @param mixed $dataSet The data to fill the options in the select list. Either an associative + * array or a database dataset. + * @param array $attributes An associative array of attributes for the list. Here is a list of + * "special" attributes and their default values: + * + * Attribute Options Default + * ------------------------------------------------------------------------ + * ValueField The name of the field in 'value' + * $dataSet that contains the + * option values. + * TextField The name of the field in 'text' + * $dataSet that contains the + * option text. + * Value A string or array of strings. $this->_DataArray->$fieldName + * Default The default value. empty + * InlineErrors Show inline error message? TRUE + * Allows disabling per-dropdown + * for multi-fields like date() + * + * @return string + */ + public function radioList($fieldName, $dataSet, $attributes = []) { + $list = val('list', $attributes); + + $return = ''; + + if ($list) { + $return .= ''; + $liOpen = 'getStyle('radio-container', '').' '.val('list-item-class', $attributes)).'>'; + $liClose = ''; + } elseif ($this->getStyle('radio-container', '') && stripos(val('class', $attributes), 'inline') === false) { + $class = $this->getStyle('radio-container'); + $liOpen = "
    "; + $liClose = '
    '; + } else { + $liOpen = ''; + $liClose = ' '; + } + + // Show inline errors? + $showErrors = ($this->_InlineErrors && array_key_exists($fieldName, $this->_ValidationResults)); + + // Add error class to input element + if ($showErrors) { + $this->addErrorClass($attributes); + } + + if (is_object($dataSet)) { + $valueField = arrayValueI('ValueField', $attributes, 'value'); + $textField = arrayValueI('TextField', $attributes, 'text'); + $data = $dataSet->firstRow(); + if (property_exists($data, $valueField) && property_exists( + $data, + $textField + ) + ) { + foreach ($dataSet->result() as $data) { + $attributes['value'] = $data->$valueField; + + $return .= $liOpen.$this->radio($fieldName, $data->$textField, $attributes).$liClose; + } + } + } elseif (is_array($dataSet)) { + foreach ($dataSet as $iD => $text) { + $attributes['value'] = $iD; + $return .= $liOpen.$this->radio($fieldName, $text, $attributes).$liClose; + } + } + + if ($list) { + $return .= ''; + } + + // Append validation error message + if ($showErrors && arrayValueI('InlineErrors', $attributes, true)) { + $return .= $this->inlineError($fieldName); + } + + return $return; + } + + /** + * Returns the xhtml for a text-based input. + * + * @param string $fieldName The name of the field that is being displayed/posted with this input. It + * should related directly to a field name in $this->_DataArray. + * @param array $attributes An associative array of attributes for the input. ie. maxlength, onclick, + * class, etc + * @return string + */ + public function textBox($fieldName, $attributes = []) { + if (!is_array($attributes)) { + $attributes = []; + } + + $multiLine = arrayValueI('MultiLine', $attributes); + + if ($multiLine) { + $attributes['rows'] = arrayValueI('rows', $attributes, '6'); // For xhtml compliance + $attributes['cols'] = arrayValueI('cols', $attributes, '100'); // For xhtml compliance + } + + // Show inline errors? + $showErrors = $this->_InlineErrors && array_key_exists($fieldName, $this->_ValidationResults); + + $cssClass = arrayValueI('class', $attributes); + if ($cssClass == false) { + $attributes['class'] = $this->getStyle($multiLine ? 'textarea' : 'textbox'); + } else { + $attributes['class'] = $this->translateClasses($cssClass); + } + + // Add error class to input element + if ($showErrors) { + $this->addErrorClass($attributes); + } + + $return = ''; + $wrap = val('Wrap', $attributes, false, true); + if ($wrap) { + $return .= '
    '; + } + + $return .= $multiLine === true ? '_idAttribute($fieldName, $attributes); + $return .= $this->_nameAttribute($fieldName, $attributes); + $return .= $multiLine === true ? '' : $this->_valueAttribute($fieldName, $attributes); + $return .= $this->_attributesToString($attributes); + + $value = arrayValueI('value', $attributes, $this->getValue($fieldName)); + + $return .= $multiLine === true ? '>'.htmlentities($value, ENT_COMPAT, 'UTF-8').'' : ' />'; + + // Append validation error message + if ($showErrors) { + $return .= $this->inlineError($fieldName); + } + + if ($wrap) { + $return .= '
    '; + } + + return $return; + } + + public function textBoxWrap($fieldName, $attributes = []) { + return '
    '.$this->textBox($fieldName, $attributes).'
    '; + } + + + /// ========================================================================= + /// Methods for interfacing with the model & db. + /// ========================================================================= + + /** + * Adds an error to the errors collection and optionally relates it to the + * specified FieldName. Errors added with this method can be rendered with + * $this->errors(). + * + * @param mixed $errorCode + * - string: The translation code that represents the error to display. + * - Exception: The exception to display the message for. + * @param string $fieldName The name of the field to relate the error to. + */ + public function addError($error, $fieldName = '') { + if (is_string($error)) { + $errorCode = $error; + } elseif (is_a($error, 'Exception')) { + if (debug()) { + // Strip the extra information out of the exception. + $parts = explode('|', $error->getMessage()); + $message = htmlspecialchars($parts[0]); + if (count($parts) >= 3) { + $fileSuffix = ": {$parts[1]}->{$parts[2]}(...)"; + } else { + $fileSuffix = ""; + } + + $errorCode = '@
    '.
    +                    $message."\n".
    +                    '## '.$error->getFile().'('.$error->getLine().")".$fileSuffix."\n".
    +                    htmlspecialchars($error->getTraceAsString()).
    +                    '
    '; + } else { + $errorCode = '@'.htmlspecialchars(strip_tags($error->getMessage())); + } + } + + if ($fieldName == '') { + $fieldName = ''; + } + + if (!is_array($this->_ValidationResults)) { + $this->_ValidationResults = []; + } + + if (!array_key_exists($fieldName, $this->_ValidationResults)) { + $this->_ValidationResults[$fieldName] = [$errorCode]; + } else { + if (!is_array($this->_ValidationResults[$fieldName])) { + $this->_ValidationResults[$fieldName] = [ + $this->_ValidationResults[$fieldName], + $errorCode]; + } else { + $this->_ValidationResults[$fieldName][] = $errorCode; + } + } + } + + /** + * Adds a hidden input value to the form. + * + * If the $forceValue parameter remains FALSE, it will grab the value into the hidden input from the form + * on postback. Otherwise it will always force the assigned value to the + * input regardless of postback. + * + * @param string $fieldName The name of the field being added as a hidden input on the form. + * @param string $value The value being assigned in the hidden input. Unless $forceValue is + * changed to TRUE, this field will be retrieved from the form upon + * postback. + * @param bool $forceValue + */ + public function addHidden($fieldName, $value = null, $forceValue = false) { + if ($this->isPostBack() && $forceValue === false) { + $value = $this->getFormValue($fieldName, $value); + } + + $this->HiddenInputs[$fieldName] = $value; + } + + /** + * Returns a boolean value indicating if the current page has an authenticated postback. + * + * It validates the postback by looking at a transient value that was rendered using $this->open() + * and submitted with the form. Ref: http://en.wikipedia.org/wiki/Cross-site_request_forgery + * + * @param bool $throw Whether or not to throw an exception if this is a postback AND the transient key doesn't validate. + * @return bool Returns true if the postback could be authenticated or false otherwise. + * @throws Gdn_UserException Throws an exception when this is a postback AND the transient key doesn't validate. + */ + public function authenticatedPostBack($throw = false) { + $keyName = 'TransientKey'; + $postBackKey = Gdn::request()->getValueFrom(Gdn_Request::INPUT_POST, $keyName, false); + + // If this isn't a postback then return false if there isn't a transient key. + if (!$postBackKey && !Gdn::request()->isPostBack()) { + return false; + } + + $result = Gdn::session()->validateTransientKey($postBackKey); + + if (!$result && $throw && Gdn::request()->isPostBack()) { + throw new Gdn_UserException(t('Invalid CSRF token.', 'Invalid CSRF token. Please try again.'), 403); + } + + return $result; + } + + /** + * Checks $this->formValues() to see if the specified button translation + * code was submitted with the form (helps figuring out what button was + * pressed to submit the form when there is more than one button available). + * + * @param string $buttonCode The translation code of the button to check for. + * @return boolean + */ + public function buttonExists($buttonCode) { + return array_key_exists($buttonCode, $this->formValues()) ? true : false; + } + + /** + * Emptys the $this->_FormValues collection so that all form fields will load empty. + */ + public function clearInputs() { + $this->_FormValues = []; + } + + /** + * Returns a count of the number of errors that have occurred. + * + * @return int + */ + public function errorCount() { + if (!is_array($this->_ValidationResults)) { + $this->_ValidationResults = []; + } + + return count($this->_ValidationResults); + } + + /** + * Returns the provided fieldname with improper characters stripped. + * + * PHP doesn't allow "." in variable names from external sources such as a + * HTML form. Some Vanilla Components however rely on variable names such + * as "a.b.c". So we need to escape them for backwards compatibility. + * + * Replaces e.g. "\" with "\\", "-dot-" with "\\-dot-" and "." with "-dot-". + * + * @see Gdn_Form::unescapeFieldName() + * + * @param string $string + * @return string + */ + public function escapeFieldName($string) { + $search = ['\\', '-dot-', '.']; + $replace = ['\\\\', '\\-dot-', '-dot-']; + return str_replace($search, $replace, $string); + } + + /** + * Unescape strings that were escaped with {@link Gdn_Form::escapeFieldName()}. + * + * Replaces e.g. "\\" with "\", "\\-dot-" with "-dot-" and "-dot-" with ".". + * + * @see Gdn_Form::escapeFieldName() + * + * @param string $string + * @return string + */ + public function unescapeFieldName($string) { + $search = ['/(?IDPrefix prepended. + * + * @param string $fieldName + * @param bool $forceUniqueID + * @return string + */ + public function escapeID( + $fieldName, + $forceUniqueID = true + ) { + $iD = $fieldName; + if (substr($iD, -2) == '[]') { + $iD = substr($iD, 0, -2); + } + + $iD = $this->IDPrefix.Gdn_Format::alphaNumeric(str_replace('.', '-dot-', $iD)); + $tmp = $iD; + $i = 1; + if ($forceUniqueID === true) { + if (array_key_exists($iD, $this->_IDCollection)) { + $tmp = $iD.$this->_IDCollection[$iD]; + $this->_IDCollection[$iD]++; + } else { + $tmp = $iD; + $this->_IDCollection[$iD] = 1; + + } + } else { + // If not forcing unique (ie. getting the id for a label's "for" tag), + // get the last used copy of the requested id. + $found = false; + $count = val($iD, $this->_IDCollection, 0); + if ($count <= 1) { + $tmp = $iD; + } else { + $tmp = $iD.($count - 1); + } + } + return $tmp; + } + + /** + * + * + * @return array + */ + public function formDataSet() { + if (is_null($this->_FormValues)) { + $this->formValues(); + } + + $result = [[]]; + foreach ($this->_FormValues as $key => $value) { + if (is_array($value)) { + foreach ($value as $rowIndex => $rowValue) { + if (!array_key_exists($rowIndex, $result)) { + $result[$rowIndex] = [$key => $rowValue]; + } else { + $result[$rowIndex][$key] = $rowValue; + } + } + } else { + $result[0][$key] = $value; + } + } + + return $result; + } + + /** + * If the form has been posted back, this method return an associative + * array of $fieldName => $value pairs which were sent in the form. + * + * Note: these values are typically used by the model and it's validation object. + * + * @return array + */ + public function formValues($newValue = null) { + if ($newValue !== null) { + $this->_FormValues = $newValue; + return; + } + + if (!is_array($this->_FormValues)) { + $this->_FormValues = []; + + $request = Gdn::request(); + $collection = $this->Method == 'get' ? $request->get() : $request->post(); + + foreach ($collection as $fieldName => $value) { + $fieldName = $this->unescapeFieldName($fieldName); + $this->_FormValues[$fieldName] = $value; + } + + // Make sure that unchecked checkboxes get added to the collection + if (array_key_exists('Checkboxes', $collection)) { + $uncheckedCheckboxes = $collection['Checkboxes']; + if (is_array($uncheckedCheckboxes) === true) { + $count = count($uncheckedCheckboxes); + for ($i = 0; $i < $count; ++$i) { + if (!array_key_exists($uncheckedCheckboxes[$i], $this->_FormValues)) { + $this->_FormValues[$uncheckedCheckboxes[$i]] = false; + } + } + } + } + + // Make sure that Date inputs (where the day, month, and year are + // separated into their own dropdowns on-screen) get added to the + // collection as a single field as well... + if (array_key_exists( + 'DateFields', + $collection + ) === true + ) { + $dateFields = $collection['DateFields']; + if (is_array($dateFields) === true) { + $count = count($dateFields); + for ($i = 0; $i < $count; ++$i) { + if (array_key_exists( + $dateFields[$i], + $this->_FormValues + ) === + false + ) { // Saving dates in the format: YYYY-MM-DD + $year = val( + $dateFields[$i]. + '_Year', + $this->_FormValues, + 0 + ); + } + $month = val( + $dateFields[$i]. + '_Month', + $this->_FormValues, + 0 + ); + $day = val( + $dateFields[$i]. + '_Day', + $this->_FormValues, + 0 + ); + $month = str_pad( + $month, + 2, + '0', + STR_PAD_LEFT + ); + $day = str_pad( + $day, + 2, + '0', + STR_PAD_LEFT + ); + $this->_FormValues[$dateFields[$i]] = $year. + '-'. + $month. + '-'. + $day; + } + } + } + } + + return $this->_FormValues; + } + + /** + * Get form data array + * + * Returns an associative array containing all the pre-propulated field data + * for the current form. + * + * @return array + */ + public function formData() { + return $this->_DataArray; + } + + /** + * Gets the value associated with $fieldName from the sent form fields. + * If $fieldName isn't found in the form, it returns $default. + * + * @param string $fieldName The name of the field to get the value of. + * @param mixed $default The default value to return if $fieldName isn't found. + * @return unknown + */ + public function getFormValue($fieldName, $default = '') { + return val($fieldName, $this->formValues(), $default); + } + + /** + * Gets the value associated with $fieldName. + * + * If the form has been posted back, it will retrieve the value from the form. + * If it hasn't been posted back, it gets the value from $this->_DataArray. + * Failing either of those, it returns $default. + * + * @param string $fieldName + * @param mixed $default + * @return mixed + * + * @todo check returned value type + */ + public function getValue($fieldName, $default = false) { + $return = ''; + // Only retrieve values from the form collection if this is a postback. + if ($this->isMyPostBack()) { + $return = $this->getFormValue($fieldName, $default); + } else { + $return = val($fieldName, $this->_DataArray, $default); + } + return $return; + } + + /** + * Disable inline errors (this is the default). + */ + public function hideErrors() { + $this->_InlineErrors = false; + } + + /** + * Examines the sent form variable collection to see if any data was sent + * via the form back to the server. Returns TRUE on if anything is found. + * + * @return boolean + */ + public function isPostBack() { + /* + 2009-01-10 - $_GET should not dictate a "post" back. + return count($_POST) > 0 ? TRUE : FALSE; + + 2009-03-31 - switching back to "get" dictating a postback + + 2012-06-27 - Using the request method to determine a postback. + */ + + switch (strtolower($this->Method)) { + case 'get': + return count($_GET) > 0 || (is_array($this->formValues()) && count($this->formValues()) > 0) ? true : false; + default: + return Gdn::request()->isPostBack(); + } + } + + /** + * Check if THIS particular form was submitted + * + * Just like isPostBack(), except auto populates FormValues and doesnt just check + * "was some data submitted lol?!". + * + * @return boolean + */ + public function isMyPostBack() { + switch (strtolower($this->Method)) { + case 'get': + return count($_GET) > 0 || (is_array($this->formValues()) && count($this->formValues()) > 0) ? true : false; + default: + return Gdn::request()->isPostBack(); + } + } + + /** + * This is a convenience method so that you don't have to code this every time + * you want to save a simple model's data. + * + * It uses the assigned model to save the sent form fields. + * If saving fails, it populates $this->_ValidationResults with validation errors & related fields. + * + * @return unknown + */ + public function save() { + $saveResult = false; + if ($this->errorCount() == 0) { + if (!isset($this->_Model)) { + trigger_error( + errorMessage( + "You cannot call the form's save method if a model has not been defined.", + "Form", + "Save" + ), + E_USER_ERROR + ); + } + + $data = $this->formValues(); + if (method_exists($this->_Model, 'FilterForm')) { + $data = $this->_Model->filterForm($this->formValues()); + } + + $args = array_merge( + func_get_args(), + [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null] + ); + $saveResult = $this->_Model->save( + $data, + $args[0], + $args[1], + $args[2], + $args[3], + $args[4], + $args[5], + $args[6], + $args[7], + $args[8], + $args[9] + ); + if ($saveResult === false) { + // NOTE: THE VALIDATION FUNCTION NAMES ARE ALSO THE LANGUAGE + // TRANSLATIONS OF THE ERROR MESSAGES. CHECK THEM OUT IN THE LOCALE + // FILE. + $this->setValidationResults($this->_Model->validationResults()); + } + } + return $saveResult; + } + + /** + * Save an image from a field. + * + * @param string $field The name of the field. The image will be uploaded with the _New extension while the current image will be just the field name. + * @param array $options + * - CurrentImage: Current image to clean if the save is successful + * @return bool + */ + public function saveImage($field, $options = []) { + $upload = new Gdn_UploadImage(); + + $fileField = str_replace('.', '_', $field); + + if (!getValueR("{$fileField}_New.name", $_FILES)) { + trace("$field not uploaded, returning."); + return false; + } + + // First make sure the file is valid. + try { + $tmpName = $upload->validateUpload($fileField.'_New', true); + + if (!$tmpName) { + return false; // no file uploaded. + } + } catch (Exception $ex) { + $this->addError($ex); + return false; + } + + // Get the file extension of the file. + $ext = val('OutputType', $options, trim($upload->getUploadedFileExtension(), '.')); + if ($ext == 'jpeg') { + $ext = 'jpg'; + } + trace($ext, 'Ext'); + + // The file is valid so let's come up with its new name. + if (isset($options['Name'])) { + $name = $options['Name']; + } elseif (isset($options['Prefix'])) + $name = $options['Prefix'].md5(microtime()).'.'.$ext; + else { + $name = md5(microtime()).'.'.$ext; + } + + // We need to parse out the size. + $size = val('Size', $options); + if ($size) { + if (is_numeric($size)) { + touchValue('Width', $options, $size); + touchValue('Height', $options, $size); + } elseif (preg_match('`(\d+)x(\d+)`i', $size, $m)) { + touchValue('Width', $options, $m[1]); + touchValue('Height', $options, $m[2]); + } + } + + trace($options, "Saving image $name."); + try { + $parsed = $upload->saveImageAs($tmpName, $name, val('Height', $options, ''), val('Width', $options, ''), $options); + trace($parsed, 'Saved Image'); + + if (val('DeleteOriginal', $options, false)) { + deprecated('Option DeleteOriginal', 'CurrentImage'); + } + + $currentImage = val('CurrentImage', $options, false); + if ($currentImage) { + trace("Deleting original image: $currentImage."); + $upload->delete($currentImage); + } + + // Set the current value. + $this->setFormValue($field, $parsed['SaveName']); + } catch (Exception $ex) { + $this->addError($ex); + } + } + + /** + * Assign a set of data to be displayed in the form elements. + * + * @param array $data A result resource or associative array containing data to be filled in + */ + public function setData($data) { + if (is_object($data) === true) { + // If this is a result object (/garden/library/database/class.dataset.php) + // retrieve it's values as arrays + if ($data instanceof DataSet) { + $resultSet = $data->resultArray(); + if (count($resultSet) > 0) { + $this->_DataArray = $resultSet[0]; + } + + } else { + // Otherwise assume it is an object representation of a data row. + $this->_DataArray = Gdn_Format::objectAsArray($data); + } + } elseif (is_array($data)) { + $this->_DataArray = $data; + } + } + + /** + * Sets the value associated with $fieldName from the sent form fields. + * Essentially overwrites whatever was retrieved from the form. + * + * @param string $fieldName The name of the field to set the value of. + * @param mixed $value The new value of $fieldName. + */ + public function setFormValue($fieldName, $value = null) { + $this->formValues(); + if (is_array($fieldName)) { + $this->_FormValues = array_merge($this->_FormValues, $fieldName); + } else { + $this->_FormValues[$fieldName] = $value; + } + } + + /** + * Remove an element from a form. + * + * @param string $fieldName + */ + public function removeFormValue($fieldName) { + $this->formValues(); + + if (!is_array($fieldName)) { + $fieldName = [$fieldName]; + } + + foreach ($fieldName as $field) { + unset($this->_FormValues[$field]); + } + } + + /** + * Set the name of the model that will enforce data rules on $this->_DataArray. + * + * This value is also used to identify fields in the $_POST or $_GET + * (depending on the forms method) collection when the form is submitted. + * + * @param Gdn_Model $model The Model that will enforce data rules on $this->_DataArray. This value + * is passed by reference so any changes made to the model outside this + * object apply when it is referenced here. + * @param Ressource $dataSet A result resource containing data to be filled in the form. + */ + public function setModel($model, $dataSet = false) { + $this->_Model = $model; + + if ($dataSet !== false) { + $this->setData($dataSet); + } + } + + /** + * + * + * @param $validationResults + */ + public function setValidationResults($validationResults) { + if (!is_array($this->_ValidationResults)) { + $this->_ValidationResults = []; + } + + $this->_ValidationResults = array_merge_recursive($this->_ValidationResults, $validationResults); + } + + /** + * Sets the value associated with $fieldName. + * + * It sets the value in $this->_DataArray rather than in $this->_FormValues. + * + * @param string $fieldName + * @param mixed $Default + */ + public function setValue($fieldName, $value) { + if (!is_array($this->_DataArray)) { + $this->_DataArray = []; + } + + $this->_DataArray[$fieldName] = $value; + } + + /** + * Enable inline errors. + */ + public function showErrors() { + $this->_InlineErrors = true; + } + + /** + * Generates a multi-field form from a schema. + * + * @param array $schema An array where each item of the array is a row that identifies a form field with the following information: + * - Name: The name of the form field. + * - Control: The type of control used for the field. This is one of the control methods on the Gdn_Form object. + * - LabelCode: The translation code for the label. Optional. + * - Description: An optional description for the field. + * - Items: If the control is a list control then its items are specified here. + * - Options: Additional options to be passed into the control. + * @param type $options Additional options to pass into the form. + * - Wrap: A two item array specifying the text to wrap the form in. + * - ItemWrap: A two item array specifying the text to wrap each form item in. + */ + public function simple($schema, $options = []) { + $result = valr('Wrap.0', $options, '
      '); + + foreach ($schema as $index => $row) { + if (is_string($row)) { + $row = ['Name' => $index, 'Control' => $row]; + } + + if (!isset($row['Name'])) { + $row['Name'] = $index; + } + if (!isset($row['Options'])) { + $row['Options'] = []; + } + + touchValue('Control', $row, 'TextBox'); + + if (strtolower($row['Control']) === 'react') { + $result .= $this->react($row['Name'], $row['Component']); + continue; + } elseif (strtolower($row['Control']) == 'callback' || strtolower($row['Control']) == 'imageuploadpreview') { + $itemWrap = ''; + } else { + $defaultWrap = ['
    • \n", "\n
    • \n"]; + $itemWrap = val('ItemWrap', $row, val('ItemWrap', $options, $defaultWrap)); + } + + $result .= $itemWrap[0]; + + $labelCode = self::labelCode($row); + + $image = ''; + + if (strtolower($row['Control']) == 'imageupload') { + $image = $this->currentImage($row['Name'], $row['Options']); + $image = wrap($image, 'div', ['class' => 'image-wrap-label']); + } + + $description = val('Description', $row, ''); + + if ($description) { + $description = wrap($description, 'div', ['class' => 'description info']); + } + + $description .= $image; + + $labelOptions = []; + if (arrayValueI('id', $row['Options'])) { + $labelOptions['for'] = arrayValueI('id', $row['Options']); + } + if ($description) { + $labelWrap = wrap($this->label($labelCode, $row['Name'], $labelOptions).$description, 'div', ['class' => 'label-wrap']); + } else { + $labelWrap = wrap($this->label($labelCode, $row['Name'], $labelOptions), 'div', ['class' => 'label-wrap']); + } + + switch (strtolower($row['Control'])) { + case 'categorydropdown': + $result .= $this->label($labelCode, $row['Name']) + .$description + .$this->categoryDropDown($row['Name'], $row['Options']); + break; + case 'checkbox': + $result .= $labelWrap + .wrap($this->checkBox($row['Name'], $labelCode, $row['Options']), 'div', ['class' => 'input-wrap']); + break; + case 'toggle': + $result .= $this->toggle($row['Name'], $labelCode, $row['Options'], $description); + break; + case 'dropdown': + $row['Options']['Wrap'] = true; + $result .= $labelWrap + .$this->dropDown($row['Name'], $row['Items'], $row['Options']); + break; + case 'radiolist': + $result .= $labelWrap + .wrap($this->radioList($row['Name'], $row['Items'], $row['Options']), 'div', ['class' => 'input-wrap']); + break; + case 'checkboxlist': + $result .= $labelWrap + .wrap($this->checkBoxList($row['Name'], $row['Items'], null, $row['Options']), 'div', ['class' => 'input-wrap']); + break; + case 'imageupload': + $result .= $labelWrap + .$this->imageUploadWrap($row['Name'], $row['Options']); + break; + case 'imageuploadpreview': + $result .= $this->imageUploadPreview($row['Name'], $labelCode, $description, $row['RemoveUrl'] ?? '', $row['Options']); + break; + case 'textbox': + $row['Options']['Wrap'] = true; + $result .= $labelWrap + .$this->textBox($row['Name'], $row['Options']); + break; + case 'callback': + $row['DescriptionHtml'] = $description; + $row['LabelCode'] = $labelCode; + $result .= call_user_func($row['Callback'], $this, $row); + break; + default: + $result .= "Error a control type of {$row['Control']} is not supported."; + break; + } + $result .= $itemWrap[1]; + } + $result .= valr('Wrap.1', $options, '
    '); + return $result; + } + + /** + * If not saving data directly to the model, this method allows you to + * utilize a model's schema to validate a form's inputs regardless. + * + * ie. A sign-in form that just needs to compare data to the model and still + * enforce it's rules. Returns the number of errors that were recorded + * through validation. + * + * @return int + */ + public function validateModel() { + $this->_Model->defineSchema(); + if ($this->_Model->Validation->validate($this->formValues()) === false) { + $this->_ValidationResults = $this->_Model->validationResults(); + } + return $this->errorCount(); + } + + /** + * Validates a rule on the form and adds its result to the errors collection. + * + * @param string $fieldName The name of the field to validate. + * @param string|array $rule The rule to validate against. + * @param string $customError A custom error string. + * @return bool Whether or not the rule succeeded. + * + * @see Gdn_Validation::validateRule() + */ + public function validateRule($fieldName, $rule, $customError = '') { + $value = $this->getFormValue($fieldName); + $valid = Gdn_Validation::validateRule($value, $fieldName, $rule, $customError); + + if ($valid === true) { + return true; + } else { + $this->addError('@'.$valid, $fieldName); + return false; + } + + } + + /** + * Gets the validation results in the form. + * + * @return array + */ + public function validationResults() { + return $this->_ValidationResults; + } + + + /** + * Takes an associative array of $attributes and returns them as a string of + * param="value" sets to be placed in an input, select, textarea, etc tag. + * + * @param array $attributes An associative array of attribute key => value pairs to be converted to a + * string. A number of "reserved" keys will be ignored: 'id', 'name', + * 'maxlength', 'value', 'method', 'action', 'type'. + * @return string + */ + protected function _attributesToString($attributes) { + $reservedAttributes = [ + 'id', + 'name', + 'value', + 'method', + 'action', + 'type', + 'for', + 'multiline', + 'default', + 'textfield', + 'valuefield', + 'includenull', + 'yearrange', + 'fields', + 'inlineerrors', + 'wrap', + 'categorydata' + ]; + $return = ''; + + // Build string from array + if (is_array($attributes)) { + foreach ($attributes as $attribute => $value) { + // Ignore reserved attributes + if (!in_array(strtolower($attribute), $reservedAttributes)) { + $return .= ' '.$attribute.($value === true ? '' : '="'.htmlspecialchars($value, ENT_COMPAT, 'UTF-8').'"'); + } + } + } + return $return; + } + + /** + * Creates an ID attribute for a form input and returns it in this format: [ id="IDNAME"] + * + * @param string $fieldName The name of the field that is being converted to an ID attribute. + * @param array $attributes An associative array of attributes for the input. ie. maxlength, onclick, + * class, etc. If $attributes contains an 'id' key, it will override the + * one automatically generated by $fieldName. + * @return string + */ + protected function _idAttribute($fieldName, $attributes) { + // ID from attributes overrides the default. + $id = arrayValueI('id', $attributes, false); + if (!$id) { + $id = $this->escapeID($fieldName); + } + + if (isset(self::$idCounters[$id])) { + $id .= self::$idCounters[$id]++; + } else { + self::$idCounters[$id] = 1; + } + + return ' id="'.htmlspecialchars($id).'"'; + } + + /** + * Creates a NAME attribute for a form input and returns it in this format: [ name="NAME"] + * + * @param string $fieldName The name of the field that is being converted to a NAME attribute. + * @param array $attributes An associative array of attributes for the input. ie. maxlength, onclick, + * class, etc. If $attributes contains a 'name' key, it will override the + * one automatically generated by $fieldName. + * @return string + */ + protected function _nameAttribute($fieldName, $attributes) { + // Name from attributes overrides the default. + $name = $this->escapeFieldName(arrayValueI('name', $attributes, $fieldName)); + return ' name="'.htmlspecialchars($name).'"'; + } + + /** + * Creates a VALUE attribute for a form input and returns it in this format: [ value="VALUE"] + * + * @param string $fieldName The name of the field that contains the value in $this->_DataArray. + * @param array $attributes An associative array of attributes for the input. ie. maxlength, onclick, + * class, etc. If $attributes contains a 'value' key, it will override the + * one automatically generated by $fieldName. + * @return string + */ + protected function _valueAttribute($fieldName, $attributes) { + // Value from $Attributes overrides the datasource and the postback. + return ' value="'.Gdn_Format::form(arrayValueI('value', $attributes, $this->getValue($fieldName))).'"'; + } +}