diff --git a/app/resources/locales/en_US/menu.po b/app/resources/locales/en_US/menu.po index 4d4dc64f3..5d8995a1f 100644 --- a/app/resources/locales/en_US/menu.po +++ b/app/resources/locales/en_US/menu.po @@ -213,6 +213,9 @@ msgstr "Toggle menu collapse button" msgid "options" msgstr "Options" +msgid "person.canvas" +msgstr "Person Canvas" + msgid "registries" msgstr "Available {0} Registries" diff --git a/app/resources/locales/en_US/operation.po b/app/resources/locales/en_US/operation.po index 41d24fec4..17babc823 100644 --- a/app/resources/locales/en_US/operation.po +++ b/app/resources/locales/en_US/operation.po @@ -57,6 +57,9 @@ msgstr "show more" msgid "autocomplete.people.desc" msgstr "Begin typing to find a person (use at least {0} characters from a name, email address, or identifier)" +msgid "autocomplete.people.field.desc" +msgstr "Begin typing to find a person (use characters from a name, email address, or identifier)" + msgid "autocomplete.people.label" msgstr "Search for a person" diff --git a/app/src/Lib/Traits/SearchFilterTrait.php b/app/src/Lib/Traits/SearchFilterTrait.php index 3a46142c1..4fe68b7b8 100644 --- a/app/src/Lib/Traits/SearchFilterTrait.php +++ b/app/src/Lib/Traits/SearchFilterTrait.php @@ -265,7 +265,7 @@ public function getSearchableAttributes(string $controller, \DateTimeZone $vv_tz // Picker configuration if(isset($f['picker'])) { $autocompleteArgs = [ - 'type' => 'default', + 'type' => 'search', 'fieldName' => $field, 'personType' => $f['picker']['type'], 'htmlId' => $field, // This is the input ID diff --git a/app/src/Lib/Util/StringUtilities.php b/app/src/Lib/Util/StringUtilities.php index b752b0fbd..ba4da3cd7 100644 --- a/app/src/Lib/Util/StringUtilities.php +++ b/app/src/Lib/Util/StringUtilities.php @@ -237,6 +237,21 @@ public static function foreignKeyToClassName(string $s): string { return Inflector::camelize(Inflector::pluralize(substr($s, 0, strlen($s)-3))); } + /** + * Determine the controller name from a foreign key (eg: report_id -> reports). + * + * @since COmanage Registry v5.1.0 + * @param string $s Foreign Key name + * @return string Class name + */ + + public static function foreignKeyToController(string $s): string { + if($s === 'affiliation_type_id') { + $s = 'type_id'; + } + return Inflector::underscore(Inflector::pluralize(substr($s, 0, strlen($s)-3))); + } + /** * Localize a controller name, accounting for plugins. * diff --git a/app/src/Model/Table/PersonRolesTable.php b/app/src/Model/Table/PersonRolesTable.php index 4c13a57ac..bdbfbd8b9 100644 --- a/app/src/Model/Table/PersonRolesTable.php +++ b/app/src/Model/Table/PersonRolesTable.php @@ -292,6 +292,17 @@ public function beforeMarshal(EventInterface $event, \ArrayObject $data, \ArrayO } } } + + $re = '/^.*\(ID: (\d+)\)$/m'; + if(!empty($data['sponsor_person_id'])) { + preg_match_all($re, $data['sponsor_person_id'], $matchesSponsor, PREG_SET_ORDER, 0); + $data['sponsor_person_id'] = $matchesSponsor[0][1]; + } + + if(!empty($data['manager_person_id'])) { + preg_match_all($re, $data['manager_person_id'], $matchesManager, PREG_SET_ORDER, 0); + $data['manager_person_id'] = $matchesManager[0][1]; + } } /** diff --git a/app/src/Model/Table/PetitionHistoryRecordsTable.php b/app/src/Model/Table/PetitionHistoryRecordsTable.php index 64791c497..f2715816c 100644 --- a/app/src/Model/Table/PetitionHistoryRecordsTable.php +++ b/app/src/Model/Table/PetitionHistoryRecordsTable.php @@ -132,29 +132,30 @@ public function generateDisplayField(\App\Model\Entity\JobHistoryRecord $entity) return __d('controller', 'PetitionHistoryRecords', [1]); } - + /** * Record a Petition History Record. * - * @since COmanage Registry v5.0.0 - * @param int $petitionId Petition ID - * @param string $enrollmentFlowStepId Enrollment Flow Step ID, or null for start or finalize - * @param string $action PetitionActionEnum - * @param string $comment Comment - * @param int $actorPersonId Actor Person ID + * @param int $petitionId Petition ID + * @param int|null $enrollmentFlowStepId Enrollment Flow Step ID, or null for start or finalize + * @param string $action PetitionActionEnum + * @param string $comment Comment + * @param int|null $actorPersonId Actor Person ID + * * @return int Petition History Record ID + * @since COmanage Registry v5.0.0 */ public function record( int $petitionId, - ?int $enrollmentFlowStepId=null, + ?int $enrollmentFlowStepId, string $action, string $comment, - ?int $actorPersonId=null + ?int $actorPersonId = null ): int { $obj = $this->newEntity([ 'petition_id' => $petitionId, - 'enrollment_flow_step_id' => $enrollmentFlowStepId, + 'enrollment_flow_step_id' => $enrollmentFlowStepId ?? null, 'action' => $action, 'comment' => $comment, 'actor_person_id' => $actorPersonId diff --git a/app/src/View/Helper/FieldHelper.php b/app/src/View/Helper/FieldHelper.php index 040dcfe3f..4a221150c 100644 --- a/app/src/View/Helper/FieldHelper.php +++ b/app/src/View/Helper/FieldHelper.php @@ -29,11 +29,12 @@ namespace App\View\Helper; +use App\Lib\Enum\DateTypeEnum; +use App\Lib\Util\StringUtilities; use Cake\I18n\FrozenTime; use Cake\Utility\Inflector; use Cake\View\Helper; -use App\Lib\Enum\DateTypeEnum; -use App\Lib\Util\StringUtilities; +use DOMDocument; class FieldHelper extends Helper { public $helpers = ['Form', 'Html']; @@ -243,15 +244,31 @@ public function constructSPAField(string $element, string $vueElementName): stri // Parse the Class attribute $regexClass = '/class="(.*?)"/m'; preg_match_all($regexClass, $element, $matchesClass, PREG_SET_ORDER, 0); + + // Parse the Value attribute + // XXX This will not work properly if the input element is a select element + if (!empty($matchesClass[0][1]) + && !str_contains($matchesClass[0][1], 'select') + ) { + $regexClass = '/value="(.*?)"/m'; + preg_match_all($regexClass, $element, $matchesValue, PREG_SET_ORDER, 0); + } + if(!empty($matchesId[0][1]) && !empty($matchesName[0][1])) { - return $this->getView()->element($vueElementName, [ + $vueElementProperties = [ 'htmlId' => $matchesId[0][1], 'fieldName' => $matchesName[0][1], 'containerClasses' => $matchesClass[0][1], 'type' => 'field', // we want the label to be an empty string to hide the default label introduced by the module. 'label' => '' - ]); + ]; + + if (isset($matchesValue[0][1])) { + $vueElementProperties['inputValue'] = $matchesValue[0][1]; + } + + return $this->getView()->element($vueElementName, $vueElementProperties); } // Fallback to an error element @@ -279,14 +296,18 @@ public function dateField(string $fieldName, $dateTitle = $dateType === DateTypeEnum::DateOnly ? 'datepicker.enterDate' : 'datepicker.enterDateTime'; $datePattern = $dateType === DateTypeEnum::DateOnly ? '\d{4}-\d{2}-\d{2}' : '\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}'; $queryParams = $this->getView()->getRequest()->getQueryParams(); - $date_object = !empty($queryParams[$fieldName]) - ? FrozenTime::parse($queryParams[$fieldName]) - : $this->getEntity()?->$fieldName; - // Petition Attribute Collection use case - if($date_object === null && !empty($fieldArgs['default'])) { - $date_object = $fieldArgs['default']; - } + $date_object = match(true) { + // filtering block + !empty($queryParams[$fieldName]) => FrozenTime::parse($queryParams[$fieldName]), + // Petition View/ Value saved as string + isset($fieldArgs['default']) && is_string($fieldArgs['default']) => FrozenTime::parse($fieldArgs['default']), + // Petition View/ Value saved a FronzenTime + isset($fieldArgs['default']) + && is_a($fieldArgs['default'], 'Cake\I18n\FrozenTime') => $fieldArgs['default'], + // Table record/ Retrieve it from the Entity object + default => $this->getEntity()?->$fieldName, + }; // Create the options array for the (text input) form control $coptions = []; @@ -397,6 +418,8 @@ public function formField(string $fieldName, || ($fieldName == 'plugin' && $this->action == 'edit'); // Selects, Checkboxes, and Radio Buttons use "disabled" + // XXX For this use case we need to add a hidden input field. If we do not we will not be able + // to post the value $fieldArgs['disabled'] = $fieldArgs['readonly']; // required can be overridden by the fields.inc, but start with the default expectation @@ -625,4 +648,47 @@ public function sourceLink($entity): string return $link; } + /** + * Iterate over form arguments, parse the generated HTML element using XMLReader, + * and inject a hidden input field if the element (e.g., select, checkbox, radio) is disabled. + * Finally, outputs the original HTML element. + * + * @param string $element + * @param array $formArguments An array containing options/attributes for the HTML form element. + * @return void + * @since COmanage Registry v5.1.0 + */ + public function getElementsForDisabledInput(string $element, array $formArguments): void + { + $orginalElement = $this->getView()->element($element, ['arguments' => $formArguments]); + if ($orginalElement) { + $htmlObj = new DOMDocument(); + $htmlObj->loadHTML($orginalElement, LIBXML_NOERROR); + if($htmlObj->getElementsByTagName('select')->length > 0) { + // Check if it is disabled. If it is then print a hidden element + foreach($htmlObj->getElementsByTagName('select')->item(0)->attributes as $attr) { + if($attr->name == 'disabled' && $attr->value == 'disabled') { + print $this->getView()->Form->hidden($formArguments['fieldName'], ['value' => $formArguments["fieldOptions"]["default"]]); + } + } + } elseif ($htmlObj->getElementsByTagName('radio')->length) { + // Check if it is disabled. If it is then print a hidden element + foreach($htmlObj->getElementsByTagName('radio')->item(0)->attributes as $attr) { + if($attr->name == 'disabled' && $attr->value == 'disabled') { + print $this->getView()->Form->hidden($formArguments['fieldName'], ['value' => $formArguments["fieldOptions"]["default"]]); + } + } + } elseif ($htmlObj->getElementsByTagName('checkbox')->length) { + // Check if it is disabled. If it is then print a hidden element + foreach($htmlObj->getElementsByTagName('checkbox')->item(0)->attributes as $attr) { + if($attr->name == 'disabled' && $attr->value == 'disabled') { + print $this->getView()->Form->hidden($formArguments['fieldName'], ['value' => $formArguments["fieldOptions"]["default"]]); + } + } + } + } + + // Print the original element + print $orginalElement; + } } \ No newline at end of file diff --git a/app/src/View/Helper/PetitionHelper.php b/app/src/View/Helper/PetitionHelper.php index 2de9a6364..0f1ddec59 100644 --- a/app/src/View/Helper/PetitionHelper.php +++ b/app/src/View/Helper/PetitionHelper.php @@ -30,11 +30,9 @@ namespace App\View\Helper; use App\Lib\Util\StringUtilities; -use App\Lib\Util\TableUtilities; use Cake\ORM\Table; use Cake\ORM\TableRegistry; use Cake\Utility\Inflector; -use Cake\Validation\Validator; use Cake\View\Helper; use CoreEnroller\Model\Table\EnrollmentAttributesTable; @@ -95,4 +93,55 @@ public function getTable(string $tableName): Table { return TableRegistry::getTableLocator()->get($tableName); } + + /** + * Fetch a record by its ID and optionally include related data. + * + * This method retrieves a specific record from the database using its ID + * and foreign key. If optional related data (associations) need to be loaded, + * they can be specified with the `$contains` parameter. + * + * @param string $foreignKey + * @param int|string $id + * @param array $contains + * + * @return array + * @since COmanage Registry v5.1.0 + */ + public function getRecordForId(string $foreignKey, int|string $id, array $contains = []): array + { + $tableName = StringUtilities::foreignKeyToClassName($foreignKey); + if($tableName === 'AffiliationTypes') { + $tableName = 'Types'; + } + $table = $this->getTable($tableName); + $query = $table->find() + ->where([$tableName . '.id' => $id]); + if(!empty($contains)) { + return $query + ->contain($contains) + ->first() + ->toArray(); + } + return $query->first()->toArray(); + } + + /** + * Transform an enrollment attribute name into a class postfix + * + * This method modifies the given attribute name by converting it + * to a format suitable for use as a CSS class postfix. + * + * @param string $attributeName Attribute name to transform + * @return string Transformed class postfix + * @since COmanage Registry v5.1.0 + */ + public function getClassPostfixFromAttributeName(string $attributeName): string + { + if(str_ends_with($attributeName, '_id')) { + $attributeName = substr($attributeName, 0, -3); + } + $attributeName = Inflector::underscore($attributeName); + return str_replace('_', '-', $attributeName); + } } \ No newline at end of file diff --git a/app/src/View/Helper/VueHelper.php b/app/src/View/Helper/VueHelper.php index 1f4b94e41..3e5043d3f 100644 --- a/app/src/View/Helper/VueHelper.php +++ b/app/src/View/Helper/VueHelper.php @@ -69,17 +69,25 @@ class VueHelper extends Helper { 'report.for', 'value.copied', ], + 'menu' => [ + 'person.canvas' + ], 'operation' => [ 'add', 'add.member', 'add.owner', 'autocomplete.pager.show.more', + 'autocomplete.people.desc', + 'autocomplete.people.field.desc', 'autocomplete.people.label', 'autocomplete.people.placeholder', 'close', 'copy', 'copy.value', + 'edit', 'primary', + 'remove', + 'view', 'visit.link', ], 'result' => [ diff --git a/app/templates/PersonRoles/fields.inc b/app/templates/PersonRoles/fields.inc index 69dc1b75d..59b8ccd73 100644 --- a/app/templates/PersonRoles/fields.inc +++ b/app/templates/PersonRoles/fields.inc @@ -59,29 +59,11 @@ if($vv_action == 'add' || $vv_action == 'edit' || $vv_action == 'view') { // For now, we render sponsor and manager as read only. // XXX Need People Picker (CFM-150) foreach(['sponsor', 'manager'] as $f) { - $fp = $f."_person"; - - $fname = ""; - $flink = []; - - if(!empty($vv_obj->$fp->names[0])) { - $fname = $vv_obj->$fp->names[0]->full_name; - $fid = $vv_obj->$fp->id; - $flink = ['url' => ['controller' => 'people', 'action' => 'edit', $vv_obj->$fp->id]]; - $formParams = [ - 'value' => $fid, - 'fullName' => $fname, - 'link' => $flink, - ]; - $this->set('formParams', $formParams); - } - + $fp = $f . '_person'; + $vv_autocomplete_arguments = [ 'fieldName' => $f.'_person_id', - 'status' => $fname, - 'link' => $flink, 'fieldLabel' => __d('field', $f), - 'fieldOptions' => [], 'autocomplete' => [ 'configuration' => [ 'action' => 'GET', @@ -90,6 +72,12 @@ if($vv_action == 'add' || $vv_action == 'edit' || $vv_action == 'view') { ] ]; + if(!empty($vv_obj->$fp->names[0])) { + $vv_autocomplete_arguments['fieldOptions'] = [ + 'default' => $vv_obj->$fp->id + ]; + } + print $this->element('form/listItem', ['arguments' => $vv_autocomplete_arguments]); } diff --git a/app/templates/element/filter/peoplePicker.php b/app/templates/element/filter/peoplePicker.php index 2eccda650..d6164cad0 100644 --- a/app/templates/element/filter/peoplePicker.php +++ b/app/templates/element/filter/peoplePicker.php @@ -67,11 +67,9 @@ } // Get the Field configuration -$formParams = $this->Filter->calculateFieldParams($key, $label); -if(!empty($formParams['value']) && $key == 'person_id') { - $formParams['fullName'] = $this->Filter->getFullName((int)$formParams['value']); -} -$vv_autocomplete_arguments['formParams'] = $formParams; +$vv_autocomplete_arguments['fieldOptions'] = $this->Filter->calculateFieldParams($key, $label); +// Update the view var +$this->set('vv_autocomplete_arguments', $vv_autocomplete_arguments); ?> diff --git a/app/templates/element/form/infoDiv/autocomplete.php b/app/templates/element/form/infoDiv/autocomplete.php index 4a816b20b..4152ca6fa 100644 --- a/app/templates/element/form/infoDiv/autocomplete.php +++ b/app/templates/element/form/infoDiv/autocomplete.php @@ -27,31 +27,13 @@ declare(strict_types = 1); -// Create a field name for the autocomplete input -$autoCompleteFieldName = 'cm_autocomplete_' . $fieldName; - -// Because we use JavaScript to set the value of the hidden field, -// disable form-tamper checking for the autocomplete fields. -// XXX We ought not have to do this for the hidden field ($fieldName) at least -$this->Form->unlockField($fieldName); -$this->Form->unlockField($autoCompleteFieldName); - -$autocompleteArgs = [ - 'type' => 'field', - 'fieldName' => $fieldName, - 'personType' => 'person', - 'htmlId' => $autoCompleteFieldName, - 'viewConfigParameters' => $vv_field_arguments['autocomplete']['configuration'] -]; +unset($vv_field_arguments['autocomplete']); +print $this->Field->constructSPAField( + // The Default field will be used to harvest the attributes + element: $this->Field->formField(...$vv_field_arguments), + // Vue/JS element + vueElementName: 'peopleAutocomplete', +); ?> - -Form->hidden($fieldName, $vv_field_arguments['fieldOptions']) . $this->element('peopleAutocomplete', $autocompleteArgs); -?> -
- info - -
diff --git a/app/templates/element/peopleAutocomplete.php b/app/templates/element/peopleAutocomplete.php index e52035a98..c545e7b1f 100644 --- a/app/templates/element/peopleAutocomplete.php +++ b/app/templates/element/peopleAutocomplete.php @@ -25,27 +25,44 @@ * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ - // Get parameters - $type = $type ?? 'stand-alone'; // autocomplete person picker type: 'stand-alone' or 'field', defaults to 'stand-alone'. - $label = $label ?? $formParams['label'] ?? __d('operation','autocomplete.people.label'); + // autocomplete person picker type, defaults to 'stand-alone': + // - 'stand-alone', used when we want to open a modal that will use the person + // - 'search', used when we find a person and display the fullname along with the ID + // - 'field', used for model records. It has a postfix with a link to the person canvas + $type = $type ?? 'stand-alone'; + // In the context of a type=field we will pass vv_field_arguments + // In the context of a stand-alone field we will have vv_autocomplete_arguments + $vv_field_arguments = $vv_field_arguments ?? $vv_autocomplete_arguments ?? []; + $label = $label ?? $vv_field_arguments["fieldLabel"] ?? __d('operation','autocomplete.people.label'); $fieldName = $fieldName ?? 'person_id'; - $personType = $personType ?? 'coperson'; + // Used by the SearchFilter Configuration + $personType = $personType ?? 'person'; $htmlId = $htmlId ?? 'cmPersonPickerId'; + // Does it have a value already. Default or stored + // CAKEPHP automatically generates a select element if the value is an integer. This is not helpful here. + $inputValue = $inputValue ?? $vv_field_arguments["fieldOptions"]["default"] ?? $vv_field_arguments["fieldOptions"]["value"] ?? ''; + + // Mainly required for the Group Members people picker since this is placed as an action url $actionUrl = $actionUrl ?? []; // the url of the page to launch on select for a stand-alone picker $viewConfigParameters = $viewConfigParameters ?? []; $containerClasses = $containerClasses ?? 'cm-autocomplete-container'; // Load my helper functions $vueHelper = $this->loadHelper('Vue'); - $inputValue = $inputValue ?? $formParams['value'] ?? ''; // If we have the $actionUrl array, construct the URL $constructedActionUrl = ''; if(!empty($actionUrl)) { $constructedActionUrl = $this->Url->build($actionUrl); } - - // Create a people autocomplete text input. + + // This is the peopleAutocomplete element. If we have the id we need to self construct the + // - the person canvas link + // - Get the person record for view or edit + if (!empty($inputValue)) { + $personRecord = $this->Petition->getRecordForId('person_id', $inputValue, ['PrimaryName', 'EmailAddresses']); + $canvasUrl = $this->Url->build(['controller' => 'people', 'action' => 'edit', $inputValue]); + } ?>