* @license GNU General Public License version 2 or later; see LICENSE.txt */ namespace Joomla\Component\Fields\Administrator\Helper; use Joomla\CMS\Factory; use Joomla\CMS\Fields\FieldsServiceInterface; use Joomla\CMS\Form\Form; use Joomla\CMS\Form\FormHelper; use Joomla\CMS\Language\Multilanguage; use Joomla\CMS\Layout\LayoutHelper; use Joomla\CMS\Object\CMSObject; use Joomla\CMS\Plugin\PluginHelper; use Joomla\Component\Fields\Administrator\Model\FieldsModel; use Joomla\Database\ParameterType; // phpcs:disable PSR1.Files.SideEffects \defined('_JEXEC') or die; // phpcs:enable PSR1.Files.SideEffects /** * FieldsHelper * * @since 3.7.0 */ class FieldsHelper { /** * @var FieldsModel */ private static $fieldsCache = null; /** * @var FieldsModel */ private static $fieldCache = null; /** * Extracts the component and section from the context string which has to * be in the format component.context. * * @param string $contextString contextString * @param object $item optional item object * * @return array|null * * @since 3.7.0 */ public static function extract($contextString, $item = null) { $parts = explode('.', $contextString, 2); if (count($parts) < 2) { return null; } $newSection = ''; $component = Factory::getApplication()->bootComponent($parts[0]); if ($component instanceof FieldsServiceInterface) { $newSection = $component->validateSection($parts[1], $item); } if ($newSection) { $parts[1] = $newSection; } return $parts; } /** * Returns the fields for the given context. * If the item is an object the returned fields do have an additional field * "value" which represents the value for the given item. If the item has an * assigned_cat_ids field, then additionally fields which belong to that * category will be returned. * Should the value being prepared to be shown in an HTML context then * prepareValue must be set to true. No further escaping needs to be done. * The values of the fields can be overridden by an associative array where the keys * have to be a name and its corresponding value. * * @param string $context The context of the content passed to the helper * @param null $item The item being edited in the form * @param int|bool $prepareValue (if int is display event): 1 - AfterTitle, 2 - BeforeDisplay, 3 - AfterDisplay, 0 - OFF * @param array|null $valuesToOverride The values to override * @param bool $includeSubformFields Should I include fields marked as Only Use In Subform? * * @return array * * @throws \Exception * @since 3.7.0 */ public static function getFields( $context, $item = null, $prepareValue = false, array $valuesToOverride = null, bool $includeSubformFields = false ) { if (self::$fieldsCache === null) { // Load the model self::$fieldsCache = Factory::getApplication()->bootComponent('com_fields') ->getMVCFactory()->createModel('Fields', 'Administrator', ['ignore_request' => true]); self::$fieldsCache->setState('filter.state', 1); self::$fieldsCache->setState('list.limit', 0); } if ($includeSubformFields) { self::$fieldsCache->setState('filter.only_use_in_subform', ''); } else { self::$fieldsCache->setState('filter.only_use_in_subform', 0); } if (is_array($item)) { $item = (object) $item; } if (Multilanguage::isEnabled() && isset($item->language) && $item->language != '*') { self::$fieldsCache->setState('filter.language', array('*', $item->language)); } self::$fieldsCache->setState('filter.context', $context); self::$fieldsCache->setState('filter.assigned_cat_ids', array()); /* * If item has assigned_cat_ids parameter display only fields which * belong to the category */ if ($item && (isset($item->catid) || isset($item->fieldscatid))) { $assignedCatIds = $item->catid ?? $item->fieldscatid; if (!is_array($assignedCatIds)) { $assignedCatIds = explode(',', $assignedCatIds); } // Fields without any category assigned should show as well $assignedCatIds[] = 0; self::$fieldsCache->setState('filter.assigned_cat_ids', $assignedCatIds); } $fields = self::$fieldsCache->getItems(); if ($fields === false) { return array(); } if ($item && isset($item->id)) { if (self::$fieldCache === null) { self::$fieldCache = Factory::getApplication()->bootComponent('com_fields') ->getMVCFactory()->createModel('Field', 'Administrator', ['ignore_request' => true]); } $fieldIds = array_map( function ($f) { return $f->id; }, $fields ); $fieldValues = self::$fieldCache->getFieldValues($fieldIds, $item->id); $new = array(); foreach ($fields as $key => $original) { /* * Doing a clone, otherwise fields for different items will * always reference to the same object */ $field = clone $original; if ($valuesToOverride && array_key_exists($field->name, $valuesToOverride)) { $field->value = $valuesToOverride[$field->name]; } elseif ($valuesToOverride && array_key_exists($field->id, $valuesToOverride)) { $field->value = $valuesToOverride[$field->id]; } elseif (array_key_exists($field->id, $fieldValues)) { $field->value = $fieldValues[$field->id]; } if (!isset($field->value) || $field->value === '') { $field->value = $field->default_value; } $field->rawvalue = $field->value; // If boolean prepare, if int, it is the event type: 1 - After Title, 2 - Before Display Content, 3 - After Display Content, 0 - Do not prepare if ($prepareValue && (is_bool($prepareValue) || $prepareValue === (int) $field->params->get('display', '2'))) { PluginHelper::importPlugin('fields'); /* * On before field prepare * Event allow plugins to modify the output of the field before it is prepared */ Factory::getApplication()->triggerEvent('onCustomFieldsBeforePrepareField', array($context, $item, &$field)); // Gathering the value for the field $value = Factory::getApplication()->triggerEvent('onCustomFieldsPrepareField', array($context, $item, &$field)); if (is_array($value)) { $value = implode(' ', $value); } /* * On after field render * Event allows plugins to modify the output of the prepared field */ Factory::getApplication()->triggerEvent('onCustomFieldsAfterPrepareField', array($context, $item, $field, &$value)); // Assign the value $field->value = $value; } $new[$key] = $field; } $fields = $new; } return $fields; } /** * Renders the layout file and data on the context and does a fall back to * Fields afterwards. * * @param string $context The context of the content passed to the helper * @param string $layoutFile layoutFile * @param array $displayData displayData * * @return NULL|string * * @since 3.7.0 */ public static function render($context, $layoutFile, $displayData) { $value = ''; /* * Because the layout refreshes the paths before the render function is * called, so there is no way to load the layout overrides in the order * template -> context -> fields. * If there is no override in the context then we need to call the * layout from Fields. */ if ($parts = self::extract($context)) { // Trying to render the layout on the component from the context $value = LayoutHelper::render($layoutFile, $displayData, null, array('component' => $parts[0], 'client' => 0)); } if ($value == '') { // Trying to render the layout on Fields itself $value = LayoutHelper::render($layoutFile, $displayData, null, array('component' => 'com_fields','client' => 0)); } return $value; } /** * PrepareForm * * @param string $context The context of the content passed to the helper * @param Form $form form * @param object $data data. * * @return boolean * * @since 3.7.0 */ public static function prepareForm($context, Form $form, $data) { // Extracting the component and section $parts = self::extract($context); if (! $parts) { return true; } $context = $parts[0] . '.' . $parts[1]; // When no fields available return here $fields = self::getFields($parts[0] . '.' . $parts[1], new CMSObject()); if (! $fields) { return true; } $component = $parts[0]; $section = $parts[1]; $assignedCatids = $data->catid ?? $data->fieldscatid ?? $form->getValue('catid'); // Account for case that a submitted form has a multi-value category id field (e.g. a filtering form), just use the first category $assignedCatids = is_array($assignedCatids) ? (int) reset($assignedCatids) : (int) $assignedCatids; if (!$assignedCatids && $formField = $form->getField('catid')) { $assignedCatids = $formField->getAttribute('default', null); if (!$assignedCatids) { // Choose the first category available $catOptions = $formField->options; if ($catOptions && !empty($catOptions[0]->value)) { $assignedCatids = (int) $catOptions[0]->value; } } $data->fieldscatid = $assignedCatids; } /* * If there is a catid field we need to reload the page when the catid * is changed */ if ($form->getField('catid') && $parts[0] != 'com_fields') { /* * Setting some parameters for the category field */ $form->setFieldAttribute('catid', 'refresh-enabled', true); $form->setFieldAttribute('catid', 'refresh-cat-id', $assignedCatids); $form->setFieldAttribute('catid', 'refresh-section', $section); } // Getting the fields $fields = self::getFields($parts[0] . '.' . $parts[1], $data); if (!$fields) { return true; } $fieldTypes = self::getFieldTypes(); // Creating the dom $xml = new \DOMDocument('1.0', 'UTF-8'); $fieldsNode = $xml->appendChild(new \DOMElement('form'))->appendChild(new \DOMElement('fields')); $fieldsNode->setAttribute('name', 'com_fields'); // Organizing the fields according to their group $fieldsPerGroup = array(0 => array()); foreach ($fields as $field) { if (!array_key_exists($field->type, $fieldTypes)) { // Field type is not available continue; } if (!array_key_exists($field->group_id, $fieldsPerGroup)) { $fieldsPerGroup[$field->group_id] = array(); } if ($path = $fieldTypes[$field->type]['path']) { // Add the lookup path for the field FormHelper::addFieldPath($path); } if ($path = $fieldTypes[$field->type]['rules']) { // Add the lookup path for the rule FormHelper::addRulePath($path); } $fieldsPerGroup[$field->group_id][] = $field; } $model = Factory::getApplication()->bootComponent('com_fields') ->getMVCFactory()->createModel('Groups', 'Administrator', ['ignore_request' => true]); $model->setState('filter.context', $context); /** * $model->getItems() would only return existing groups, but we also * have the 'default' group with id 0 which is not in the database, * so we create it virtually here. */ $defaultGroup = new \stdClass(); $defaultGroup->id = 0; $defaultGroup->title = ''; $defaultGroup->description = ''; $iterateGroups = array_merge(array($defaultGroup), $model->getItems()); // Looping through the groups foreach ($iterateGroups as $group) { if (empty($fieldsPerGroup[$group->id])) { continue; } // Defining the field set /** @var \DOMElement $fieldset */ $fieldset = $fieldsNode->appendChild(new \DOMElement('fieldset')); $fieldset->setAttribute('name', 'fields-' . $group->id); $fieldset->setAttribute('addfieldpath', '/administrator/components/' . $component . '/models/fields'); $fieldset->setAttribute('addrulepath', '/administrator/components/' . $component . '/models/rules'); $label = $group->title; $description = $group->description; if (!$label) { $key = strtoupper($component . '_FIELDS_' . $section . '_LABEL'); if (!Factory::getLanguage()->hasKey($key)) { $key = 'JGLOBAL_FIELDS'; } $label = $key; } if (!$description) { $key = strtoupper($component . '_FIELDS_' . $section . '_DESC'); if (Factory::getLanguage()->hasKey($key)) { $description = $key; } } $fieldset->setAttribute('label', $label); $fieldset->setAttribute('description', strip_tags($description)); // Looping through the fields for that context foreach ($fieldsPerGroup[$group->id] as $field) { try { Factory::getApplication()->triggerEvent('onCustomFieldsPrepareDom', array($field, $fieldset, $form)); /* * If the field belongs to an assigned_cat_id but the assigned_cat_ids in the data * is not known, set the required flag to false on any circumstance. */ if (!$assignedCatids && !empty($field->assigned_cat_ids) && $form->getField($field->name)) { $form->setFieldAttribute($field->name, 'required', 'false'); } } catch (\Exception $e) { Factory::getApplication()->enqueueMessage($e->getMessage(), 'error'); } } // When the field set is empty, then remove it if (!$fieldset->hasChildNodes()) { $fieldsNode->removeChild($fieldset); } } // Loading the XML fields string into the form $form->load($xml->saveXML()); $model = Factory::getApplication()->bootComponent('com_fields') ->getMVCFactory()->createModel('Field', 'Administrator', ['ignore_request' => true]); if ( (!isset($data->id) || !$data->id) && Factory::getApplication()->input->getCmd('controller') == 'modules' && Factory::getApplication()->isClient('site') ) { // Modules on front end editing don't have data and an id set $data->id = Factory::getApplication()->input->getInt('id'); } // Looping through the fields again to set the value if (!isset($data->id) || !$data->id) { return true; } foreach ($fields as $field) { $value = $model->getFieldValue($field->id, $data->id); if ($value === null) { continue; } if (!is_array($value) && $value !== '') { // Function getField doesn't cache the fields, so we try to do it only when necessary $formField = $form->getField($field->name, 'com_fields'); if ($formField && $formField->forceMultiple) { $value = (array) $value; } } // Setting the value on the field $form->setValue($field->name, 'com_fields', $value); } return true; } /** * Return a boolean if the actual logged in user can edit the given field value. * * @param \stdClass $field The field * * @return boolean * * @since 3.7.0 */ public static function canEditFieldValue($field) { $parts = self::extract($field->context); return Factory::getUser()->authorise('core.edit.value', $parts[0] . '.field.' . (int) $field->id); } /** * Return a boolean based on field (and field group) display / show_on settings * * @param \stdClass $field The field * * @return boolean * * @since 3.8.7 */ public static function displayFieldOnForm($field) { $app = Factory::getApplication(); // Detect if the field should be shown at all if ($field->params->get('show_on') == 1 && $app->isClient('administrator')) { return false; } elseif ($field->params->get('show_on') == 2 && $app->isClient('site')) { return false; } if (!self::canEditFieldValue($field)) { $fieldDisplayReadOnly = $field->params->get('display_readonly', '2'); if ($fieldDisplayReadOnly == '2') { // Inherit from field group display read-only setting $groupModel = $app->bootComponent('com_fields') ->getMVCFactory()->createModel('Group', 'Administrator', ['ignore_request' => true]); $groupDisplayReadOnly = $groupModel->getItem($field->group_id)->params->get('display_readonly', '1'); $fieldDisplayReadOnly = $groupDisplayReadOnly; } if ($fieldDisplayReadOnly == '0') { // Do not display field on form when field is read-only return false; } } // Display field on form return true; } /** * Gets assigned categories ids for a field * * @param \stdClass[] $fieldId The field ID * * @return array Array with the assigned category ids * * @since 4.0.0 */ public static function getAssignedCategoriesIds($fieldId) { $fieldId = (int) $fieldId; if (!$fieldId) { return array(); } $db = Factory::getDbo(); $query = $db->getQuery(true); $query->select($db->quoteName('a.category_id')) ->from($db->quoteName('#__fields_categories', 'a')) ->where('a.field_id = ' . $fieldId); $db->setQuery($query); return $db->loadColumn(); } /** * Gets assigned categories titles for a field * * @param \stdClass[] $fieldId The field ID * * @return array Array with the assigned categories * * @since 3.7.0 */ public static function getAssignedCategoriesTitles($fieldId) { $fieldId = (int) $fieldId; if (!$fieldId) { return []; } $db = Factory::getDbo(); $query = $db->getQuery(true); $query->select($db->quoteName('c.title')) ->from($db->quoteName('#__fields_categories', 'a')) ->join('INNER', $db->quoteName('#__categories', 'c') . ' ON a.category_id = c.id') ->where($db->quoteName('field_id') . ' = :fieldid') ->bind(':fieldid', $fieldId, ParameterType::INTEGER); $db->setQuery($query); return $db->loadColumn(); } /** * Gets the fields system plugin extension id. * * @return integer The fields system plugin extension id. * * @since 3.7.0 */ public static function getFieldsPluginId() { $db = Factory::getDbo(); $query = $db->getQuery(true) ->select($db->quoteName('extension_id')) ->from($db->quoteName('#__extensions')) ->where($db->quoteName('folder') . ' = ' . $db->quote('system')) ->where($db->quoteName('element') . ' = ' . $db->quote('fields')); $db->setQuery($query); try { $result = (int) $db->loadResult(); } catch (\RuntimeException $e) { Factory::getApplication()->enqueueMessage($e->getMessage(), 'error'); $result = 0; } return $result; } /** * Loads the fields plugins and returns an array of field types from the plugins. * * The returned array contains arrays with the following keys: * - label: The label of the field * - type: The type of the field * - path: The path of the folder where the field can be found * * @return array * * @since 3.7.0 */ public static function getFieldTypes() { PluginHelper::importPlugin('fields'); $eventData = Factory::getApplication()->triggerEvent('onCustomFieldsGetTypes'); $data = array(); foreach ($eventData as $fields) { foreach ($fields as $fieldDescription) { if (!array_key_exists('path', $fieldDescription)) { $fieldDescription['path'] = null; } if (!array_key_exists('rules', $fieldDescription)) { $fieldDescription['rules'] = null; } $data[$fieldDescription['type']] = $fieldDescription; } } return $data; } /** * Clears the internal cache for the custom fields. * * @return void * * @since 3.8.0 */ public static function clearFieldsCache() { self::$fieldCache = null; self::$fieldsCache = null; } }