diff --git a/app/config/schema/schema.json b/app/config/schema/schema.json index 2a4f45b43..d70478c0a 100644 --- a/app/config/schema/schema.json +++ b/app/config/schema/schema.json @@ -111,7 +111,10 @@ "required_fields_address": { "type": "string", "size": 160 }, "required_fields_name": { "type": "string", "size": 160 }, "search_global_limit": { "type": "integer" }, - "search_global_limited_models": { "type": "boolean" } + "search_global_limited_models": { "type": "boolean" }, + "person_picker_email_type": { "type": "integer" }, + "person_picker_identifier_type": { "type": "integer" }, + "person_picker_display_types": { "type": "boolean" } }, "indexes": { "co_settings_i1": { "columns": [ "co_id" ]}, diff --git a/app/resources/locales/en_US/field.po b/app/resources/locales/en_US/field.po index ac02352bc..007b9c871 100644 --- a/app/resources/locales/en_US/field.po +++ b/app/resources/locales/en_US/field.po @@ -101,6 +101,9 @@ msgstr "Display Name" msgid "edupersonaffiliation" msgstr "eduPersonAffiliation" +msgid "email" +msgstr "Email" + msgid "ends_at" msgstr "Ends at:" @@ -125,6 +128,9 @@ msgstr "Given Name" msgid "group_membership" msgstr "{0} Membership in {1}" +msgid "hidden" +msgstr "Hidden" + msgid "honorific" msgstr "Honorific" @@ -342,6 +348,15 @@ msgstr "Name Permitted Fields" msgid "CoSettings.permitted_fields_telephone_number" msgstr "Telephone Number Permitted Fields" +msgid "CoSettings.person_picker_display_fields" +msgstr "Person Picker Display Fields" + +msgid "CoSettings.person_picker_display_fields.desc" +msgstr "This determines what fields to display alongside the person name when using the autocomplete Person Picker (e.g. for selecting Group members)." + +msgid "CoSettings.person_picker_display_types" +msgstr "Display field types in the picker" + msgid "CoSettings.required_fields_address" msgstr "Address Required Fields" diff --git a/app/resources/locales/en_US/operation.po b/app/resources/locales/en_US/operation.po index 092608bc4..4d3f3bfb1 100644 --- a/app/resources/locales/en_US/operation.po +++ b/app/resources/locales/en_US/operation.po @@ -33,6 +33,24 @@ msgstr "Add" msgid "add.a" msgstr "Add a New {0}" +msgid "add.bulk" +msgstr "Bulk add" + +msgid "add.member" +msgstr "Add member: " + +msgid "autocomplete.pager.show.more" +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.label" +msgstr "Search for a person" + +msgid "autocomplete.people.placeholder" +msgstr "enter name, email address, or identifier" + msgid "api.key.generate" msgstr "Generate API Key" diff --git a/app/src/Controller/ApiV2Controller.php b/app/src/Controller/ApiV2Controller.php index e40f30841..5386c46e4 100644 --- a/app/src/Controller/ApiV2Controller.php +++ b/app/src/Controller/ApiV2Controller.php @@ -41,7 +41,8 @@ class ApiV2Controller extends AppController { use \App\Lib\Traits\LabeledLogTrait; - + use \App\Lib\Traits\IndexQueryTrait; + /** * Perform Cake Controller initialization. * @@ -77,6 +78,8 @@ public function initialize(): void { public function add() { // $this->name = Models $modelsName = $this->name; + // $table = the actual table object + $table = $this->$modelsName; // $tableName = models $tableName = $this->tableName; @@ -144,15 +147,20 @@ public function beforeRender(\Cake\Event\EventInterface $event) { public function delete($id) { // $this->name = Models (ie: from ModelsTable) $modelsName = $this->name; - + // $table = the actual table object + $table = $this->$modelsName; + // $tableName = models + $tableName = $table->getTable(); + + // Make sure the requested object exists try { - $obj = $this->$modelsName->findById($id)->firstOrFail(); - + $obj = $table->findById($id)->firstOrFail(); + // XXX document AR-CO-1 when we implement hard delete/changelog // note similar logic in StandardController - $this->$modelsName->deleteOrFail($obj); - + $table->deleteOrFail($obj); + if(method_exists($obj, "isReadOnly") && $obj->isReadOnly()) { throw new BadRequestException(__d('error', 'edit.readonly')); } @@ -184,10 +192,12 @@ public function delete($id) { public function edit($id) { // $this->name = Models (ie: from ModelsTable) $modelsName = $this->name; + // $table = the actual table object + $table = $this->$modelsName; // $tableName = models - $tableName = $this->$modelsName->getTable(); + $tableName = $table->getTable(); - $query = $this->$modelsName->findById($id); + $query = $table->findById($id); try { // Pull the current record @@ -203,14 +213,14 @@ public function edit($id) { throw new BadRequestException(__d('error', 'api.object', [$modelsName])); } - $obj = $this->$modelsName->patchEntity($obj, $json[$modelsName]); + $obj = $table->patchEntity($obj, $json[$modelsName]); - $this->$modelsName->saveOrFail($obj); + $table->saveOrFail($obj); // Trigger provisioning, letting errors bubble up (AR-GMR-5) - if(method_exists($this->$modelsName, "requestProvisioning")) { + if(method_exists($table, "requestProvisioning")) { $this->llog('rule', "AR-GMR-5 Requesting provisioning for $modelsName " . $obj->id); - $this->$modelsName->requestProvisioning(id: $obj->id, context: ProvisioningContextEnum::Automatic); + $table->requestProvisioning(id: $obj->id, context: ProvisioningContextEnum::Automatic); } // Let the view render @@ -224,7 +234,6 @@ public function edit($id) { $err = $this->exceptionToError($e); $this->llog('debug', $err); - $results[] = ['error' => $err]; throw new BadRequestException($this->exceptionToError($e)); } @@ -293,48 +302,26 @@ public function generateApiKey(string $id) { public function index() { // $modelsName = Models $modelsName = $this->name; - - $query = $this->$modelsName->find(); - - // PrimaryLinkTrait - $link = $this->getPrimaryLink(true); - - // We automatically allow API calls to be filtered on primary link - if(!empty($link->attr) && !empty($link->value)) { - $query = $query->where([$this->$modelsName->getAlias().'.'.$link->attr => $link->value]); - } + // $table = the actual table object + $table = $this->$modelsName; - // This will produce a nested object which is very useful for vue integration - if($this->request->getQuery('extended') !== null) { - $modelContain = []; - $associations = $this->$modelsName->associations(); - foreach($associations->getByType(['BelongsTo']) as $a) { - $modelContain[] = $a->getClassName(); - } - - if(!empty($modelContain)) { - $query = $query->contain($modelContain); + $reqParameters = []; + $pickerMode = false; + if($this->request->is('ajax')) { + $reqParameters = [...$this->request->getQuery()]; + if($this->request->getQuery('picker') !== null) { + $pickerMode = filter_var($this->request->getQuery('picker'), FILTER_VALIDATE_BOOLEAN); } } - if($modelsName == 'AuthenticationEvents') { - // Special case for filtering on authenticated identifier. There is a - // similar filter in AuthenticationEventsController::beforeFilter. - // If other special cases show up this should get refactored into a trait - // populated by the table (or something similar). - - if($this->getRequest()->getQuery('authenticated_identifier')) { - $query = $query->where(['authenticated_identifier' => \App\Lib\Util\StringUtilities::urlbase64decode($this->getRequest()->getQuery('authenticated_identifier'))]); - } else { - // We only allow unfiltered queries for platform users - - if(!$this->RegistryAuth->isPlatformAdmin()) { - throw new \InvalidArgumentException(__d('error', 'input.notprov', 'authenticated_identifier')); - } - } + + // Construct the Query + $query = $this->getIndexQuery($pickerMode, $reqParameters); + + if(method_exists($table, 'findIndexed')) { + $query = $table->findIndexed($query); } - - // This magically makes REST calls paginated... can use eg direction=, + // This magically makes REST calls paginated... can use eg direction=, // sort=, limit=, page= $this->set($this->tableName, $this->paginate($query)); @@ -351,14 +338,16 @@ public function index() { public function view($id = null) { // $this->name = Models $modelsName = $this->name; + // $table = the actual table object + $table = $this->$modelsName; // $tableName = models - $tableName = $this->$modelsName->getTable(); + $tableName = $table->getTable(); if(empty($id)) { throw new InvalidArgumentException(__d('error', 'notprov', ['id'])); } - $obj = $this->$modelsName->findById($id)->firstOrFail(); + $obj = $table->findById($id)->firstOrFail(); $this->set($tableName, [$obj]); diff --git a/app/src/Controller/GroupMembersController.php b/app/src/Controller/GroupMembersController.php index 3d54f1087..b258d32db 100644 --- a/app/src/Controller/GroupMembersController.php +++ b/app/src/Controller/GroupMembersController.php @@ -30,6 +30,8 @@ namespace App\Controller; // XXX not doing anything with Log yet +use Cake\Event\EventInterface; +use Cake\Http\Response; use Cake\Log\Log; class GroupMembersController extends StandardController { @@ -38,15 +40,17 @@ class GroupMembersController extends StandardController { 'People.primary_name.name' => 'asc' ] ]; - + /** * Callback run prior to the request render. * + * @param EventInterface $event Cake Event + * + * @return Response|void * @since COmanage Registry v5.0.0 - * @param EventInterface $event Cake Event */ - public function beforeRender(\Cake\Event\EventInterface $event) { + public function beforeRender(EventInterface $event) { // Pull the Group name for breadcrumb rendering $link = $this->getPrimaryLink(true); @@ -58,4 +62,26 @@ public function beforeRender(\Cake\Event\EventInterface $event) { return parent::beforeRender($event); } + + /** + * Handle an add action for a Group Member. + * + * @since COmanage Registry v5.0.0 + */ + + public function add() { + // If we have a person_id in the request, the person has been pre-selected. + if(!empty($this->request->getQuery('person_id'))) { + $personId = $this->request->getQuery('person_id'); + $Names = $this->getTableLocator()->get('Names'); + $personName = $Names->primaryName((int)$personId)->full_name; + $selectedPerson = [ + 'id' => $personId, + 'name' => $personName + ]; + $this->set('vv_selected_person', $selectedPerson); + } + + return parent::add(); + } } \ No newline at end of file diff --git a/app/src/Controller/StandardController.php b/app/src/Controller/StandardController.php index b4883090d..9e2df306d 100644 --- a/app/src/Controller/StandardController.php +++ b/app/src/Controller/StandardController.php @@ -29,6 +29,8 @@ namespace App\Controller; +use Cake\Database\Expression\QueryExpression; +use Cake\ORM\TableRegistry; use InvalidArgumentException; use \Cake\Http\Exception\BadRequestException; use \App\Lib\Enum\ProvisioningContextEnum; @@ -36,6 +38,8 @@ use \App\Lib\Util\StringUtilities; class StandardController extends AppController { + use \App\Lib\Traits\IndexQueryTrait; + // Pagination defaults should be set in each controller public $pagination = []; @@ -209,7 +213,7 @@ public function beforeRender(\Cake\Event\EventInterface $event) { // Check to see if the model names a specific layout if(method_exists($table, "getLayout")) { - $this->viewBuilder()->setLayout($table->getLayout()); + $this->viewBuilder()->setLayout($table->getLayout($this->request->getParam('action'))); } return parent::beforeRender($event); @@ -564,73 +568,22 @@ public function index() { $table = $this->$modelsName; // $tableName = models $tableName = $table->getTable(); + // Construct the Query + $query = $this->getIndexQuery(); - $query = null; - - // PrimaryLinkTrait - $link = $this->getPrimaryLink(true); - - // AutoViewVarsTrait - $this->populateAutoViewVars(); - - if(!empty($link->attr)) { - // If a link attribute is defined but no value is provided, then query - // where the link attribute is NULL - // "all" is the default finder. But since we are utilizing the paginator here, we will check the configuration - // for any custom finder. - $query = $table->find( - $this->paginate['finder'] ?? "all" - )->where([$table->getAlias().'.'.$link->attr => $link->value]); - } else { - $query = $table->find($this->paginate['finder'] ?? "all"); - } - - // QueryModificationTrait - if(method_exists($table, "getIndexContains") - && $table->getIndexContains()) { - $query->contain($table->getIndexContains()); - } - - // SearchFilterTrait - if(method_exists($table, "getSearchableAttributes")) { - $searchableAttributes = $table->getSearchableAttributes($this->name, $this->viewBuilder()->getVar('vv_tz')); - - if(!empty($searchableAttributes)) { - // Here we iterate over the attributes, and we add a new where clause for each one - foreach($searchableAttributes as $attribute => $options) { - if(!empty($this->request->getQuery($attribute))) { - $query = $table->whereFilter($query, $attribute, $this->request->getQuery($attribute)); - } elseif (!empty($this->request->getQuery($attribute . "_starts_at")) - || !empty($this->request->getQuery($attribute . "_ends_at"))) { - $search_date = []; - // We allow empty for dates since we might refer to infinity (from whenever or to always) - $search_date[] = $this->request->getQuery($attribute . "_starts_at") ?? ""; - $search_date[] = $this->request->getQuery($attribute . "_ends_at") ?? ""; - $query = $table->whereFilter($query, $attribute, $search_date); - } - } - - $this->set('vv_searchable_attributes', $searchableAttributes); - } + if(method_exists($table, 'findIndexed')) { + $query = $table->findIndexed($query); } - - // Filter on requested filter, if requested - // QueryModificationTrait - if(method_exists($table, "getIndexFilter")) { - $filter = $table->getIndexFilter(); - - if(is_callable($filter)) { - $query->where($filter($this->request)); - } else { - $query->where($table->getIndexFilter()); - } - } - + + // Fetch the data and paginate $resultSet = $this->paginate($query); - + + // Pass vars to the View $this->set($tableName, $resultSet); $this->set('vv_permission_set', $this->RegistryAuth->calculatePermissionsForResultSet($resultSet)); - + // AutoViewVarsTrait + $this->populateAutoViewVars(); + // Default index view title is model name [$title, , ] = StringUtilities::entityAndActionToTitle($resultSet, $modelsName, 'index'); $this->set('vv_title', $title); @@ -701,10 +654,9 @@ protected function populateAutoViewVars(object $obj=null) { case 'auxiliary': // XXX add list as in match? case 'select': - // We assume $modelName has a direct relationship to $avv['model'] $avvmodel = $avv['model']; - $this->$avvmodel = $this->fetchTable($avvmodel); - + $this->$avvmodel = TableRegistry::getTableLocator()->get($avvmodel); + if($avv['type'] == 'auxiliary') { $query = $this->$avvmodel->find(); } else { @@ -777,7 +729,7 @@ protected function populateAutoViewVars(object $obj=null) { break; default: // XXX I18n? and in match? - throw new \LogicException('Unknonwn Auto View Var Type {0}', [$avv['type']]); + throw new \LogicException('Unknonwn Auto View Var Type {0}', $avv['type']); break; } } diff --git a/app/src/Lib/Enum/PeoplePickerModeEnum.php b/app/src/Lib/Enum/PeoplePickerModeEnum.php new file mode 100644 index 000000000..ca12001e8 --- /dev/null +++ b/app/src/Lib/Enum/PeoplePickerModeEnum.php @@ -0,0 +1,36 @@ +name = Models + $modelsName = $this->name; + // $table = the actual table object + $table = $this->$modelsName; + // Initialize the containClause + $containClause = []; + + // Get whatever the table configuration has + if(method_exists($table, 'getIndexContains') + && $table->getIndexContains()) { + $containClause = $table->getIndexContains(); + } + + // Examples: + // 1. GET https://example.com/registry-pe/api/v2/people?co_id=2&limit=10&extended=PrimaryName,EmailAddresses,Identifiers + // 2. GET https://example.com/registry-pe/api/v2/people?co_id=2&limit=10&extended=on + // 2. GET https://example.com/registry-pe/api/v2/people?co_id=2&limit=10&extended=all + // 1. GET https://example.com/registry-pe/api/v2/people?co_id=2&limit=10 + if($this->request->is('restful')|| $this->request->is('ajax')) { + // Restfull and ajax do not include the IndexContains by default. + $containClause = []; + // Set the extended query param to `on` in order to fetch the indexContains + if( + $this->request->getQuery('extended') && + filter_var($this->request->getQuery('extended'), FILTER_VALIDATE_BOOLEAN) + ) { + $containClause = $table->getIndexContains(); + } elseif( + $this->request->getQuery('extended') && + $this->request->getQuery('extended') === 'all' + ) { + // Get all the associated models + $associations = $table->associations(); + foreach($associations->getIterator() as $a) { + $containClause[] = $a->getName(); + } + } elseif ( + $this->request->getQuery('extended') + && \is_string($this->request->getQuery('extended')) + ) { + // Get ONLY the associated models requested + $associations = $table->associations(); + $containQueryList = str_getcsv($this->request->getQuery('extended')); + foreach($associations->getIterator() as $a) { + if(\in_array($a->getName(), $containQueryList, true)) { + $containClause[] = $a->getName(); + } + } + } + } + + return empty($containClause) ? $query : $query->contain($containClause); + } + + /** + * Build the Index Query + * + * @params boolean $pickerMode True for OR and False for AND. AND is the default behavior + * @params array $requestParams + * + * @return object Cake ORM Query object + * @since COmanage Registry v5.0.0 + */ + public function getIndexQuery(bool $pickerMode = false, array $requestParams = []): object { + // $this->name = Models + $modelsName = $this->name; + // $table = the actual table object + $table = $this->$modelsName; + // PrimaryLinkTrait + $link = $this->getPrimaryLink(true); + // Initialize the Query Object + $query = $table->find($this->paginate['finder'] ?? 'all'); + // Get a pointer to my expressions list + $newexp = $query->newExpr(); + // The searchable attributes can have an AND or an OR conjunction. The first one is used from the filtering block + // while the second one from the picker vue module. + $newexp = $newexp->setConjunction($pickerMode ? 'OR' : 'AND'); + + if(!empty($link->attr) && !empty($link->value)) { + // If a link attribute is defined but no value is provided, then query + // where the link attribute is NULL + // "all" is the default finder. But since we are utilizing the paginator here, we will check the configuration + // for any custom finder. + $query = $query->where([$table->getAlias().'.'.$link->attr => $link->value]); + } + + // Get Associated Model Data + $query = $this->constructGetIndexContains($query); + + // Attributes to search for + if(method_exists($table, 'getSearchableAttributes')) { + $searchableAttributes = $table->getSearchableAttributes($this->name, + $this->viewBuilder()->getVar('vv_tz')); + + if(!empty($searchableAttributes)) { + $this->set('vv_searchable_attributes', $searchableAttributes); + + // Here we iterate over the attributes, and we add a new where clause for each one + foreach($searchableAttributes as $attribute => $options) { + // Add the Join Clauses + $query = $table->addJoins($query, $attribute, $this->request); + + // Construct and apply the where Clause + if(!empty($this->request->getQuery($attribute))) { + $newexp = $table->expressionsConstructor($query, $newexp, $attribute, $this->request->getQuery($attribute)); + } elseif (!empty($this->request->getQuery($attribute . '_starts_at')) + || !empty($this->request->getQuery($attribute . '_ends_at'))) { + $search_date = []; + // We allow empty for dates since we might refer to infinity (from whenever or to always) + $search_date[] = $this->request->getQuery($attribute . '_starts_at') ?? ''; + $search_date[] = $this->request->getQuery($attribute . '_ends_at') ?? ''; + $newexp = $table->expressionsConstructor($query, $newexp, $attribute, $search_date); + } + } + + // Append the new conditions + $query = $query->where($newexp); + } + } + + // Filter results that will occur from the searchable attributes + if($pickerMode) { + // Get only the active People + // XXX Perhaps we need to make this a configuration + $query = $query->where(fn(QueryExpression $exp, Query $query) => $exp->in($table->getAlias().'.status', [StatusEnum::Active, StatusEnum::GracePeriod])); + + // Specific expressions per view + $query = match($requestParams['for'] ?? '') { + // GroupMembers Add view: We need to filter the active members + 'GroupMembers' => $query->leftJoinWith('GroupMembers', fn($q) => $q->where(['GroupMembers.group_id' => (int)$requestParams['groupid'] ?? -1])) + ->where($this->getTableLocator()->get('GroupMembers')->checkValidity($query)) + ->where(fn(QueryExpression $exp, Query $query) => $exp->isNull('GroupMembers.' . StringUtilities::classNameToForeignKey($table->getAlias()))), + // Just return the query + default => $query + }; + } + + // Special Authenticated Identifier filtering + if($modelsName == 'AuthenticationEvents') { + // Special case for filtering on authenticated identifier. There is a + // todo: + // similar filter in AuthenticationEventsController::beforeFilter. + // If other special cases show up this should get refactored into a trait + // populated by the table (or something similar). + + if($this->getRequest()->getQuery('authenticated_identifier')) { + $query = $query->where(['authenticated_identifier' => StringUtilities::urlbase64decode($this->getRequest()->getQuery('authenticated_identifier'))]); + } else { + // We only allow unfiltered queries for platform users + + if(!$this->RegistryAuth->isPlatformAdmin()) { + throw new \InvalidArgumentException(__d('error', 'input.notprov', 'authenticated_identifier')); + } + } + } + + return $query; + } +} \ No newline at end of file diff --git a/app/src/Lib/Traits/QueryModificationTrait.php b/app/src/Lib/Traits/QueryModificationTrait.php index acdd704bb..1aa061bcd 100644 --- a/app/src/Lib/Traits/QueryModificationTrait.php +++ b/app/src/Lib/Traits/QueryModificationTrait.php @@ -29,6 +29,10 @@ namespace App\Lib\Traits; +use Cake\Database\Expression\QueryExpression; +use Cake\ORM\Query; +use Cake\Utility\Inflector; + trait QueryModificationTrait { // Array of associated models to copy during a duplicate private $duplicateContains = false; @@ -47,7 +51,33 @@ trait QueryModificationTrait { // Array of associated models to pull during a view private $viewContains = false; - + + + /** + * Construct the checkValidity for the fields valid_from and valid_through + * + * @param Query $query + * + * @return QueryExpression + * @since COmanage Registry v5.0.0 + */ + public function checkValidity(Query $query): QueryExpression { + $fieldModelPrefix = Inflector::pluralize(substr($this->getEntityClass(), strrpos($this->getEntityClass(), '\\')+1)); + + $exp = $query->newExpr(); + $orValidFromConditions = $exp->or( + fn(QueryExpression $or) => $or->isNull($fieldModelPrefix . '.valid_from') + ->lt($fieldModelPrefix . '.valid_from', date('Y-m-d H:i:s')) + ); + $orValidThroughConditions = $exp->or( + fn(QueryExpression $or) => $or->isNull($fieldModelPrefix . '.valid_through') + ->gt($fieldModelPrefix . '.valid_through', date('Y-m-d H:i:s')) + ); + + return $exp->add($orValidFromConditions) + ->add($orValidThroughConditions); + } + /** * Obtain the set of associated models to copy during a duplicate. * @@ -81,17 +111,6 @@ public function getIndexContains() { return $this->indexContains; } - /** - * Obtain the index filter for this model. - * - * @since COmanage Registry v5.0.0 - * @return array|Closure Array of index filters or closure that generates an array - */ - - public function getIndexFilter(): array|\Closure|null { - return $this->indexFilter; - } - /** * Obtain the set of associated models to save during a patch. * diff --git a/app/src/Lib/Traits/SearchFilterTrait.php b/app/src/Lib/Traits/SearchFilterTrait.php index d90440646..cbe4fba65 100644 --- a/app/src/Lib/Traits/SearchFilterTrait.php +++ b/app/src/Lib/Traits/SearchFilterTrait.php @@ -29,36 +29,140 @@ namespace App\Lib\Traits; +use Cake\Database\Expression\QueryExpression; +use Cake\Http\ServerRequest; +use Cake\ORM\Query; use Cake\Utility\Inflector; use Cake\I18n\FrozenTime; trait SearchFilterTrait { // Array (and configuration) of permitted search filters - private $searchFilters = array(); + private array $searchFilters = []; // Optional filter configuration that dictates display state and allows for related models - private $filterConfig = array(); - + private array $filterConfig = []; + + /** + * Build the query join associations + * + * @param Query $query + * @param string $attribute + * @param ServerRequest $request + * @param string $joinType + * + * @return object Cake ORM Query object + * @since COmanage Registry v5.0.0 + */ + public function addJoins(Query $query, string $attribute, ServerRequest $request, string $joinType = 'INNER'): object { + // not a permitted attribute + if(empty($this->searchFilters[$attribute]) + || $request->getQuery($attribute) === null + || !isset($this->searchFilters[$attribute]['model'])) { + return $query; + } + + $changelog_fk = strtolower(Inflector::underscore($this->searchFilters[$attribute]['model'])) . '_id'; + $fk = strtolower(Inflector::underscore(Inflector::singularize($this->_alias))) . '_id'; + $mtable_name = Inflector::tableize(Inflector::pluralize($this->searchFilters[$attribute]['model'])); + $mtable_alias = Inflector::pluralize($this->searchFilters[$attribute]['model']); + + return $query->join([$mtable_alias => [ + 'table' => $mtable_name, + 'conditions' => [ + $mtable_alias . '.' . $fk . '=' . $this->_alias . '.id', +// XXX Moved to changelong Behavior +// $mtable_alias . '.' . 'deleted IS NOT TRUE', +// $mtable_alias . '.' . $changelog_fk . ' IS NULL' + ], + 'type' => $joinType + ]]); + } + + /** + * Construct the Date comparison clause from the query parameters + * + * @param QueryExpression $exp + * + * @param string $attributeWithModelPrefix Model.attribute as required by CAKE ORM + * @param array $dates Contains the list of starting and ending dates in the following order [starts_at, ends_at] + * + * @return QueryExpression + * @since COmanage Registry v5.0.0 + */ + public function constructDateComparisonClause(QueryExpression $exp, string $attributeWithModelPrefix, array $dates): QueryExpression { + // Both are empty, just return + if (empty($dates[0]) && empty($dates[1])) { + return $exp; + } + // The starts_at is non-empty. So the data should be greater than the starts_at date + if (!empty($dates[0]) && empty($dates[1])) { + return $exp->gte("'" . FrozenTime::parse($dates[0]) . "'", $attributeWithModelPrefix); + } + // The ends_at is non-empty. So the data should be less than the ends_at date + if (!empty($dates[1]) + && empty($dates[0])) { + return $exp->lte("'" . FrozenTime::parse($dates[1]) . "'", $attributeWithModelPrefix); + } + + return $exp->between($attributeWithModelPrefix, "'" . $dates[0] . "'", "'" . $dates[1] . "'"); + } + + /** + * Build a query where() clause for the configured attribute. + * + * @param Query $query + * @param QueryExpression $exp + * @param string $attribute Attribute to filter on (database name) + * @param string|array $q Value to filter on + * + * @return object Cake ORM Query object + * @since COmanage Registry v5.0.0 + */ + + public function expressionsConstructor(Query $query, QueryExpression $exp, string $attribute, string|array $q): object { + // not a permitted attribute + if(empty($this->searchFilters[$attribute])) { + return $exp; + } + + // Prepend the Model name to the attribute + $attributeWithModelPrefix = isset($this->searchFilters[$attribute]['model']) ? + Inflector::pluralize($this->searchFilters[$attribute]['model']) . '.' . $attribute : + $this->_alias . '.' . $attribute; + + $search = $q; + // Use the `lower` function to apply uniformity for the search + $lower = $query->func()->lower([$attributeWithModelPrefix => 'identifier']); + + return match ($this->searchFilters[$attribute]['type']) { + 'string' => $exp->like($lower, strtolower('%' . $search . '%')), + 'integer', 'boolean' => $exp->add([$attributeWithModelPrefix => $search]), + 'date' => $exp->add([$attributeWithModelPrefix => FrozenTime::parseDate($search, 'y-M-d')]), + 'timestamp' => $this->constructDateComparisonClause($search), + default => $exp->eq($lower, strtolower($search)) + }; + } + /** * Get explicilty defined filter configuration defined in the table class. * * @since COmanage Registry v5.0.0 */ - + public function getFilterConfig(): array { return $this->filterConfig; } - + /** * Obtain the set of permitted search attributes. * * @since COmanage Registry v5.0.0 * @return array Array of permitted search attributes and configuration elements needed for display */ - + public function getSearchableAttributes(string $controller, string $vv_tz=null): array { $modelname = Inflector::classify(Inflector::underscore($controller)); $filterConfig = $this->getFilterConfig(); - + // Gather up related models defined in the $filterConfig // XXX For now, we'll list these first - but we should probably provide a better way to order these. foreach ($filterConfig as $field => $f) { @@ -67,28 +171,27 @@ public function getSearchableAttributes(string $controller, string $vv_tz=null): $this->searchFilters[$field] = [ 'type' => 'string', // XXX for now - this needs to be looked up. 'label' => \App\Lib\Util\StringUtilities::columnKey($fieldName, $field, $vv_tz, true), - 'active' => isset($f['active']) ? $f['active'] : true, + 'active' => $f['active'] ?? true, 'model' => $f['model'], 'order' => $f['order'] ]; } } - + foreach ($this->filterMetadataFields() as $column => $type) { // If the column is an array then we are accessing the Metadata fields. Skip if(is_array($type)) { continue; } - + // Set defaults $fieldIsActive = true; - + // Gather filter configurations, if any, for local table fields. // An active field is visible in the filter form. An inactive field is not but can be enabled. - if(!empty($filterConfig[$column])) { - if(isset($filterConfig[$column]['active'])) { - $fieldIsActive = $filterConfig[$column]['active']; - } + if(!empty($filterConfig[$column]) + && isset($filterConfig[$column]['active'])) { + $fieldIsActive = $filterConfig[$column]['active']; } $attribute = [ @@ -122,101 +225,15 @@ public function getSearchableAttributes(string $controller, string $vv_tz=null): return $this->searchFilters ?? []; } - + /** * Set explicilty defined filter configuration defined in the table class. - * + * * @since COmanage Registry v5.0.0 */ - + public function setFilterConfig(array $filterConfig): void { $this->filterConfig = $filterConfig; } - /** - * Build a query where() clause for the configured attribute. - * - * @param \Cake\ORM\Query $query Cake ORM Query object - * @param string $attribute Attribute to filter on (database name) - * @param string|array $q Value to filter on - * - * @return \Cake\ORM\Query Cake ORM Query object - * @since COmanage Registry v5.0.0 - */ - - public function whereFilter(\Cake\ORM\Query $query, string $attribute, string|array $q): object { - // not a permitted attribute - if(empty($this->searchFilters[$attribute])) { - return $query; - } - - if(isset($this->searchFilters[$attribute]['model'])) { - $changelog_fk = strtolower(Inflector::underscore($this->searchFilters[$attribute]['model'])) . '_id'; - $fk = strtolower(Inflector::underscore(Inflector::singularize($this->_alias))) . '_id'; - $mtable_name = Inflector::tableize(Inflector::pluralize($this->searchFilters[$attribute]['model'])); - $mtable_alias = Inflector::pluralize($this->searchFilters[$attribute]['model']); - $query->join([$mtable_alias => [ - 'table' => $mtable_name, - 'conditions' => [ - $mtable_alias . '.' . $fk . '=' . $this->_alias . '.id', - $mtable_alias . '.' . 'deleted IS NOT TRUE', - $mtable_alias . '.' . $changelog_fk . ' IS NULL' - ], - 'type' => 'INNER' - ]]); - } - - // Prepend the Model name to the attribute - $attributeWithModelPrefix = isset($this->searchFilters[$attribute]['model']) ? - Inflector::pluralize($this->searchFilters[$attribute]['model']) . '.' . $attribute : - $this->_alias . '.' . $attribute; - - $search = $q; - $sub = false; - // Primitive types - $search_types = ['integer', 'boolean']; - if( $this->searchFilters[$attribute]['type'] == "string") { - $search = "%" . $search . "%"; - $sub = true; - // Search type - } elseif(in_array($this->searchFilters[$attribute]['type'], $search_types, true)) { - return $query->where([$attributeWithModelPrefix => $search]); - // Date - } elseif($this->searchFilters[$attribute]['type'] == "date") { - // Parse the date string with FrozenTime to improve error handling - return $query->where([$attributeWithModelPrefix => FrozenTime::parseDate($search, 'y-M-d')]); - // Timestamp - } elseif( $this->searchFilters[$attribute]['type'] == "timestamp") { - // Date between dates - if(!empty($search[0]) - && !empty($search[1])) { - return $query->where(function (\Cake\Database\Expression\QueryExpression $exp, \Cake\ORM\Query $query) use ($attributeWithModelPrefix, $search) { - return $exp->between($attributeWithModelPrefix, "'" . $search[0] . "'", "'" . $search[1] . "'"); - }); - // The starts at is non-empty. So the data should be greater than the starts_at date - } elseif(!empty($search[0]) - && empty($search[1])) { - return $query->where(function (\Cake\Database\Expression\QueryExpression $exp, \Cake\ORM\Query $query) use ($attributeWithModelPrefix, $search) { - return $exp->gte("'" . FrozenTime::parse($search[0]) . "'", $attributeWithModelPrefix); - }); - // The ends at is non-empty. So the data should be less than the ends at date - } elseif(!empty($search[1]) - && empty($search[0])) { - return $query->where(function (\Cake\Database\Expression\QueryExpression $exp, \Cake\ORM\Query $query) use ($attributeWithModelPrefix, $search) { - return $exp->lte("'" . FrozenTime::parse($search[1]) . "'", $attributeWithModelPrefix); - }); - } else { - // We return everything - return $query; - } - - } - - // String values - return $query->where(function (\Cake\Database\Expression\QueryExpression $exp, \Cake\ORM\Query $query) use ($attributeWithModelPrefix, $search, $sub) { - $lower = $query->func()->lower([$attributeWithModelPrefix => 'identifier']); - return ($sub) ? $exp->like($lower, strtolower($search)) - : $exp->eq($lower, strtolower($search)); - }); - } } diff --git a/app/src/Model/Behavior/ChangelogBehavior.php b/app/src/Model/Behavior/ChangelogBehavior.php index be2afabee..fad6f5610 100644 --- a/app/src/Model/Behavior/ChangelogBehavior.php +++ b/app/src/Model/Behavior/ChangelogBehavior.php @@ -29,7 +29,10 @@ namespace App\Model\Behavior; +use Cake\Datasource\EntityInterface; +use Cake\Event\Event; use Cake\ORM\Behavior; +use Cake\ORM\Query; use Cake\Utility\Inflector; class ChangelogBehavior extends Behavior @@ -44,7 +47,7 @@ class ChangelogBehavior extends Behavior * @return boolean True on success */ - public function beforeDelete(\Cake\Event\Event $event, $entity, \ArrayObject $options) { + public function beforeDelete(Event $event, $entity, \ArrayObject $options) { if(isset($options['useHardDelete']) && $options['useHardDelete']) { // Hard delete requested, so just return return true; @@ -74,7 +77,7 @@ public function beforeDelete(\Cake\Event\Event $event, $entity, \ArrayObject $op // But return success return true; } - + /** * Adjust find query conditions for changelog. * @@ -85,7 +88,7 @@ public function beforeDelete(\Cake\Event\Event $event, $entity, \ArrayObject $op * @param boolean $primary Whether or not this is the root query (vs an associated query) */ - public function beforeFind(\Cake\Event\Event $event, \Cake\ORM\Query $query, \ArrayObject $options, bool $primary) { + public function beforeFind(Event $event, Query $query, \ArrayObject $options, bool $primary) { if(isset($options['archived']) && $options['archived']) { // Archived records requested (including possiblf expunge), so just return return true; @@ -100,7 +103,16 @@ public function beforeFind(\Cake\Event\Event $event, \Cake\ORM\Query $query, \Ar // XXX add support for archived, revision, etc // XXX if specific id is requested, do not modify query - + + // Take into account all joined associations + if(!empty($query->clause('join'))) { + foreach($query->clause('join') as $mdl => $opts) { + $ascParentfk = Inflector::singularize($opts['table']) . '_id'; + + $query->where([$opts['alias'] . '.deleted IS NOT true']) + ->where([$opts['alias'] . '.' . $ascParentfk . ' IS NULL']); + } + } // We use IS NOT TRUE to check for null || false, since pre-Changelog data // may have null instead of false. // (Alternately we could join two clauses for false || IS NULL.) @@ -121,7 +133,7 @@ public function beforeFind(\Cake\Event\Event $event, \Cake\ORM\Query $query, \Ar * @param ArrayObject $options Options */ - public function beforeSave(\Cake\Event\Event $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options) { + public function beforeSave(Event $event, EntityInterface $entity, \ArrayObject $options) { // XXX prevent updates to deleted and archived records // Cake Book suggests doing this with Application Rules... can we define those in the Behavior? // or perhaps in beforeMarshal? https://book.cakephp.org/3.0/en/orm/saving-data.html#modifying-request-data-before-building-entities diff --git a/app/src/Model/Table/AdHocAttributesTable.php b/app/src/Model/Table/AdHocAttributesTable.php index 922535577..3bdb8a142 100644 --- a/app/src/Model/Table/AdHocAttributesTable.php +++ b/app/src/Model/Table/AdHocAttributesTable.php @@ -50,8 +50,10 @@ class AdHocAttributesTable extends Table { * @since COmanage Registry v5.0.0 * @return string Type of redirect */ - public function getLayout(): string { - return "iframe"; + public function getLayout(string $action = ''): string { + return match($action) { + default => 'iframe' + }; } /** diff --git a/app/src/Model/Table/AddressesTable.php b/app/src/Model/Table/AddressesTable.php index f42adb038..6ddd34615 100644 --- a/app/src/Model/Table/AddressesTable.php +++ b/app/src/Model/Table/AddressesTable.php @@ -64,8 +64,10 @@ class AddressesTable extends Table { * @since COmanage Registry v5.0.0 * @return string Type of redirect */ - public function getLayout(): string { - return "iframe"; + public function getLayout(string $action = ''): string { + return match($action) { + default => 'iframe' + }; } /** diff --git a/app/src/Model/Table/CoSettingsTable.php b/app/src/Model/Table/CoSettingsTable.php index d66fea2f5..0f2bccf02 100644 --- a/app/src/Model/Table/CoSettingsTable.php +++ b/app/src/Model/Table/CoSettingsTable.php @@ -55,6 +55,7 @@ class CoSettingsTable extends Table { use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\TableMetaTrait; + use \App\Lib\Traits\ValidationTrait; /** * Perform Cake Model initialization. @@ -82,10 +83,18 @@ public function initialize(array $config): void { ->setClassName('Types') ->setForeignKey('default_email_address_type_id') ->setProperty('default_email_address_type'); + $this->belongsTo('PersonPickerEmailAddressType') + ->setClassName('Types') + ->setForeignKey('person_picker_email_address_type_id') + ->setProperty('person_picker_email_address_type'); $this->belongsTo('DefaultIdentifierTypes') ->setClassName('Types') ->setForeignKey('default_identifier_type_id') ->setProperty('default_identifier_type'); + $this->belongsTo('PersonPickerIdentifierTypes') + ->setClassName('Types') + ->setForeignKey('person_picker_identifier_type_id') + ->setProperty('person_picker_identifier_type'); $this->belongsTo('DefaultNameTypes') ->setClassName('Types') ->setForeignKey('default_name_type_id') @@ -102,7 +111,7 @@ public function initialize(array $config): void { ->setClassName('Types') ->setForeignKey('default_url_type_id') ->setProperty('default_url_type'); - + $this->setDisplayField('co_id'); $this->setPrimaryLink('co_id'); @@ -123,6 +132,14 @@ public function initialize(array $config): void { 'type' => 'type', 'attribute' => 'Identifiers.type' ], + 'personPickerEmailAddressTypes' => [ + 'type' => 'type', + 'attribute' => 'EmailAddresses.type' + ], + 'personPickerIdentifierTypes' => [ + 'type' => 'type', + 'attribute' => 'Identifiers.type' + ], 'defaultNameTypes' => [ 'type' => 'type', 'attribute' => 'Names.type' @@ -197,6 +214,9 @@ public function addDefaults(int $coId): int { 'default_url_type_id' => null, 'permitted_fields_name' => PermittedNameFieldsEnum::HGMFS, 'permitted_fields_telephone_number' => PermittedTelephoneNumberFieldsEnum::CANE, + 'person_picker_email_type' => null, + 'person_picker_identifier_type' => null, + 'person_picker_display_types' => true, 'required_fields_address' => RequiredAddressFieldsEnum::Street, 'required_fields_name' => RequiredNameFieldsEnum::Given, 'search_global_limit' => DEF_GLOBAL_SEARCH_LIMIT, @@ -347,6 +367,22 @@ public function validationDefault(Validator $validator): Validator { ]); $validator->notEmptyString('search_global_limit'); - return $validator; + $validator->add('person_picker_email_type', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('person_picker_email_type'); + + $validator->add('person_picker_identifier_type', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('person_picker_identifier_type'); + + + $validator->add('person_picker_display_types', [ + 'content' => ['rule' => 'boolean'] + ]); + $validator->allowEmptyString('person_picker_display_types'); + + return $validator; } } \ No newline at end of file diff --git a/app/src/Model/Table/EmailAddressesTable.php b/app/src/Model/Table/EmailAddressesTable.php index 0cd60fe6d..717874537 100644 --- a/app/src/Model/Table/EmailAddressesTable.php +++ b/app/src/Model/Table/EmailAddressesTable.php @@ -66,8 +66,10 @@ class EmailAddressesTable extends Table { * @since COmanage Registry v5.0.0 * @return string Type of redirect */ - public function getLayout(): string { - return "iframe"; + public function getLayout(string $action = ''): string { + return match($action) { + default => 'iframe' + }; } /** diff --git a/app/src/Model/Table/GroupMembersTable.php b/app/src/Model/Table/GroupMembersTable.php index c5a697a72..b93f803b9 100644 --- a/app/src/Model/Table/GroupMembersTable.php +++ b/app/src/Model/Table/GroupMembersTable.php @@ -29,9 +29,12 @@ namespace App\Model\Table; +use App\Model\Entity\GroupMember; +use Cake\Database\Expression\QueryExpression; use Cake\ORM\Query; use Cake\ORM\RulesChecker; use Cake\ORM\Table; +use Cake\Utility\Inflector; use Cake\Validation\Validator; use \App\Lib\Enum\ActionEnum; use \App\Lib\Enum\GroupTypeEnum; @@ -50,6 +53,19 @@ class GroupMembersTable extends Table { use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; + /** + * Provide the default layout + * + * @since COmanage Registry v5.0.0 + * @return string Type of redirect + */ + public function getLayout(string $action = ''): string { + return match($action) { + 'add','edit','view' => 'iframe', + default => 'default' + }; + } + /** * Perform Cake Model initialization. * @@ -75,15 +91,28 @@ public function initialize(array $config): void { $this->setPrimaryLink(['group_id', 'person_id']); $this->setRequiresCO(true); + $this->setRedirectGoal('self'); $this->setEditContains(['Groups', 'People.PrimaryName']); - + $this->setViewContains(['Groups', 'People.PrimaryName']); + $this->setIndexContains([ 'GroupNestings' => 'Groups', 'Groups', 'People.PrimaryName' ]); - + + $this->setAutoViewVars([ + 'cosettings' => [ + 'type' => 'auxiliary', + 'model' => 'CoSettings' + ], + 'types' => [ + 'type' => 'auxiliary', + 'model' => 'Types' + ] + ]); + $this->setPermissions([ // XXX update for couAdmins, group owners, etc // Actions that operate over an entity (ie: require an $id) @@ -126,7 +155,7 @@ public function buildRules(RulesChecker $rules): RulesChecker { * @return string Display field */ - public function generateDisplayField(\App\Model\Entity\GroupMember $entity): string { + public function generateDisplayField(GroupMember $entity): string { // Pull the group and person information to build a more useful display string return __d('field', 'group_membership', [$entity->person->primary_name->full_name, $entity->group->name]); @@ -149,35 +178,22 @@ public function isMember(int $groupId, bool $checkValidity=true): bool { // This function is here (instead of GroupsTable) because we need it for // rule validation on new GroupMember save. - - $conditions = [ - 'group_id' => $groupId, - 'person_id' => $personId - ]; - - if($checkValidity) { - // Only pull currently valid group memberships - - $conditions['AND'][] = [ - 'OR' => [ - 'valid_from IS NULL', - 'valid_from < ' => date('Y-m-d H:i:s', time()) - ] - ]; - $conditions['AND'][] = [ - 'OR' => [ - 'valid_through IS NULL', - 'valid_through > ' => date('Y-m-d H:i:s', time()) - ] - ]; - } - + + $query = $this->find() + ->where(['group_id' => $groupId]) + ->where(['person_id' => $personId]); + if($direct) { // XXX need to add pipelines here eventually - $conditions[] = 'group_nesting_id IS NULL'; + $query = $query->where(fn(QueryExpression $exp, Query $query) => $exp->isNull('group_nesting_id')); } - - $count = $this->find()->where($conditions)->count(); + + if($checkValidity) { + $queryCheckValidityExp = $this->checkValidity($query); + $query = $query->where($queryCheckValidityExp); + } + + $count = $query->count(); // When !$direct, we could get more than one row back return ($count > 0); diff --git a/app/src/Model/Table/IdentifiersTable.php b/app/src/Model/Table/IdentifiersTable.php index 7b3b3d2cf..881bd1587 100644 --- a/app/src/Model/Table/IdentifiersTable.php +++ b/app/src/Model/Table/IdentifiersTable.php @@ -80,8 +80,10 @@ class IdentifiersTable extends Table { * @since COmanage Registry v5.0.0 * @return string Type of redirect */ - public function getLayout(): string { - return "iframe"; + public function getLayout(string $action = ''): string { + return match($action) { + default => 'iframe' + }; } /** diff --git a/app/src/Model/Table/NamesTable.php b/app/src/Model/Table/NamesTable.php index 60e218933..d43d5eff5 100644 --- a/app/src/Model/Table/NamesTable.php +++ b/app/src/Model/Table/NamesTable.php @@ -72,8 +72,10 @@ class NamesTable extends Table { * @since COmanage Registry v5.0.0 * @return string Type of redirect */ - public function getLayout(): string { - return "iframe"; + public function getLayout(string $action = ''): string { + return match($action) { + default => 'iframe' + }; } /** diff --git a/app/src/Model/Table/PeopleTable.php b/app/src/Model/Table/PeopleTable.php index 64d337900..6d13c9aba 100644 --- a/app/src/Model/Table/PeopleTable.php +++ b/app/src/Model/Table/PeopleTable.php @@ -289,12 +289,11 @@ public function beforeDelete(\Cake\Event\Event $event, $entity, \ArrayObject $op * Customized finder for the Index Population View * * @param Query $query Cake ORM Query - * @param array $options Cake ORM Query options * - * @return CakeORMQuery Cake ORM Query + * @return Query Cake ORM Query * @since COmanage Registry v5.0.0 */ - public function findIndexed(Query $query, array $options): Query { + public function findIndexed(Query $query): Query { return $query->select([ 'People.id', 'PrimaryName.given', diff --git a/app/src/Model/Table/PronounsTable.php b/app/src/Model/Table/PronounsTable.php index b380170d0..e02753daf 100644 --- a/app/src/Model/Table/PronounsTable.php +++ b/app/src/Model/Table/PronounsTable.php @@ -60,8 +60,10 @@ class PronounsTable extends Table { * @since COmanage Registry v5.0.0 * @return string Type of redirect */ - public function getLayout(): string { - return "iframe"; + public function getLayout(string $action = ''): string { + return match($action) { + default => 'iframe' + }; } /** diff --git a/app/src/Model/Table/TelephoneNumbersTable.php b/app/src/Model/Table/TelephoneNumbersTable.php index baed829d5..11da53dde 100644 --- a/app/src/Model/Table/TelephoneNumbersTable.php +++ b/app/src/Model/Table/TelephoneNumbersTable.php @@ -65,8 +65,10 @@ class TelephoneNumbersTable extends Table { * @since COmanage Registry v5.0.0 * @return string Type of redirect */ - public function getLayout(): string { - return "iframe"; + public function getLayout(string $action = ''): string { + return match($action) { + default => 'iframe' + }; } /** diff --git a/app/src/Model/Table/UrlsTable.php b/app/src/Model/Table/UrlsTable.php index 9c4fe052f..ab2550d9b 100644 --- a/app/src/Model/Table/UrlsTable.php +++ b/app/src/Model/Table/UrlsTable.php @@ -60,8 +60,10 @@ class UrlsTable extends Table { * @since COmanage Registry v5.0.0 * @return string Type of redirect */ - public function getLayout(): string { - return "iframe"; + public function getLayout(string $action = ''): string { + return match($action) { + default => 'iframe' + }; } /** diff --git a/app/src/View/Helper/FieldHelper.php b/app/src/View/Helper/FieldHelper.php index 0ff99a9bc..87bec56ae 100644 --- a/app/src/View/Helper/FieldHelper.php +++ b/app/src/View/Helper/FieldHelper.php @@ -70,21 +70,23 @@ public function banner(string $info): string { $this->Alert->alert($info, 'warning') . ''; } - + /** * Emit a form control. * - * @since COmanage Registry v5.0.0 - * @param string $fieldName Form field - * @param array $options FormHelper control options - * @param string $labelText Label text (fieldName language key used by default) - * @param string $ctrlCode Control code passed in from wrapper functions - * @param string $cssClass Start li css class passed in from wrapper functions - * @param string $beforeField Markup to be placed before/above the field - * @param string $afterField Markup to be placed after/below the field - * @param string $prefix Field prefix - used for API Usernames - * @param bool $labelIsTextOnly For fields that should not include