diff --git a/src/applications/project/customfield/PhabricatorProjectDescriptionField.php b/src/applications/project/customfield/PhabricatorProjectDescriptionField.php index aaeeaeceb5..1da7ce1305 100644 --- a/src/applications/project/customfield/PhabricatorProjectDescriptionField.php +++ b/src/applications/project/customfield/PhabricatorProjectDescriptionField.php @@ -1,18 +1,19 @@ array( 'name' => pht('Description'), 'type' => 'remarkup', 'description' => pht('Short project description.'), + 'fulltext' => PhabricatorSearchField::FIELD_BODY, ), )); } } diff --git a/src/docs/user/configuration/custom_fields.diviner b/src/docs/user/configuration/custom_fields.diviner index d20734ed27..ff430e4290 100644 --- a/src/docs/user/configuration/custom_fields.diviner +++ b/src/docs/user/configuration/custom_fields.diviner @@ -1,207 +1,210 @@ @title Configuring Custom Fields @group config How to add custom fields to applications which support them. = Overview = Several Phabricator applications allow the configuration of custom fields. These fields allow you to add more information to objects, and in some cases reorder or remove builtin fields. For example, you could use custom fields to add an "Estimated Hours" field to tasks, a "Lead" field to projects, or a "T-Shirt Size" field to users. These applications currently support custom fields: | Application | Support | |-------------|---------| | Maniphest | Full Support | | Projects | Full Support | | People | Full Support | | Differential | Partial Support | | Diffusion | Limited Support | Custom fields can appear in many interfaces and support search, editing, and other features. = Basic Custom Fields = To get started with custom fields, you can use configuration to select and reorder fields and to add new simple fields. If you don't need complicated display controls or sophisticated validation, these simple fields should cover most use cases. They allow you to attach things like strings, numbers, and dropdown menus to objects. The relevant configuration settings are: | Application | Add Fields | Select Fields | |-------------|------------|---------------| | Maniphest | `maniphest.custom-field-definitions` | `maniphest.fields` | | Projects | `projects.custom-field-definitions` | `projects.fields` | | People | `user.custom-field-definitions` | `user.fields` | | Differential | Planned | `differential.fields` | | Diffusion | Planned | Planned | When adding fields, you'll specify a JSON blob like this (for example, as the value of `maniphest.custom-field-definitions`): { "mycompany:estimated-hours": { "name": "Estimated Hours", "type": "int", "caption": "Estimated number of hours this will take.", "required": true }, "mycompany:actual-hours": { "name": "Actual Hours", "type": "int", "caption": "Actual number of hours this took." }, "mycompany:company-jobs": { "name": "Job Role", "type": "select", "options": { "mycompany:engineer": "Engineer", "mycompany:nonengineer": "Other" } }, "mycompany:favorite-dinosaur": { "name": "Favorite Dinosaur", "type": "text" } } The fields will then appear in the other config option for the application (for example, in `maniphest.fields`) and you can enable, disable, or reorder them. For details on how to define a field, see the next section. = Custom Field Configuration = When defining custom fields using a configuration option like `maniphest.custom-field-definitions`, these options are available: - **name**: Display label for the field on the edit and detail interfaces. - **description**: Optional text shown when managing the field. - **type**: Field type. The supported field types are: - **int**: An integer, rendered as a text field. - **text**: A string, rendered as a text field. - **bool**: A boolean value, rendered as a checkbox. - **select**: Allows the user to select from several options as defined by **options**, rendered as a dropdown. - **remarkup**: A text area which allows the user to enter markup. - **users**: A typeahead which allows multiple users to be input. - **date**: A date/time picker. - **header**: Renders a visual divider which you can use to group fields. - **edit**: Show this field on the application's edit interface (this defaults to `true`). - **view**: Show this field on the application's view interface (this defaults to `true`). (Note: Empty fields are not shown.) - **search**: Show this field on the application's search interface, allowing users to filter objects by the field value. + - **fulltext**: Index the text in this field as part of the object's global + full-text index. This allows users to find the object by searching for + the field's contents using global search. - **caption**: A caption to display underneath the field (optional). - **required**: True if the user should be required to provide a value. - **options**: If type is set to **select**, provide options for the dropdown as a dictionary. - **default**: Default field value. - **strings**: Allows you to override specific strings based on the field type. See below. - **instructions**: Optional block of remarkup text which will appear above the control when rendered on the edit view. - **placeholder**: A placeholder text that appears on text boxes. Only supported in text, int and remarkup fields (optional). The `strings` value supports different strings per control type. They are: - **bool** - **edit.checkbox** Text for the edit interface, no default. - **view.yes** Text for the view interface, defaults to "Yes". - **search.default** Text for the search interface, defaults to "(Any)". - **search.require** Text for the search interface, defaults to "Require". Some applications have specific options which only work in that application. In **Maniphest**: - **copy**: When a user creates a task, the UI gives them an option to "Create Another Similar Task". Some fields from the original task are copied into the new task, while others are not; by default, fields are not copied. If you want this field to be copied, specify `true` for the `copy` property. Internally, Phabricator implements some additional custom field types and options. These are not intended for general use and are subject to abrupt change, but are documented here for completeness: - **Credentials**: Controls with type `credential` allow selection of a Passphrase credential which provides `credential.provides`, and creation of credentials of `credential.type`. = Advanced Custom Fields = If you want custom fields to have advanced behaviors (sophisticated rendering, advanced validation, complicated controls, interaction with other systems, etc), you can write a custom field as an extension and add it to Phabricator. NOTE: This API is somewhat new and fairly large. You should expect that there will be occasional changes to the API requiring minor updates in your code. To do this, extend the appropriate `CustomField` class for the application you want to add a field to: | Application | Extend | |-------------|---------| | Maniphest | @{class:ManiphestCustomField} | | Projects | @{class:PhabricatorProjectCustomField} | | People | @{class:PhabricatorUserCustomField} | | Differential | @{class:DifferentialCustomField} | | Diffusion | @{class:PhabricatorCommitCustomField} | The easiest way to get started is to drop your subclass into `phabricator/src/extensions/`, which should make it immediately available in the UI (if you use APC, you may need to restart your webserver). For example, this is a simple template which adds a custom field to Maniphest: name=ExampleManiphestCustomField.php 'color: #ff00ff', ), pht('It worked!')); } } Broadly, you can then add features by overriding more methods and implementing them. Many of the native fields are implemented on the custom field architecture, and it may be useful to look at them. For details on available integrations, see the base class for your application and @{class:PhabricatorCustomField}. = Next Steps = Continue by: - learning more about extending Phabricator with custom code in @{article:libphutil Libraries User Guide}; - or returning to the @{article: Configuration Guide}. diff --git a/src/infrastructure/customfield/field/PhabricatorCustomFieldList.php b/src/infrastructure/customfield/field/PhabricatorCustomFieldList.php index d7ff2a3585..15b83928e1 100644 --- a/src/infrastructure/customfield/field/PhabricatorCustomFieldList.php +++ b/src/infrastructure/customfield/field/PhabricatorCustomFieldList.php @@ -1,347 +1,345 @@ fields = $fields; } public function getFields() { return $this->fields; } public function setViewer(PhabricatorUser $viewer) { $this->viewer = $viewer; foreach ($this->getFields() as $field) { $field->setViewer($viewer); } return $this; } /** * Read stored values for all fields which support storage. * * @param PhabricatorCustomFieldInterface Object to read field values for. * @return void */ public function readFieldsFromStorage( PhabricatorCustomFieldInterface $object) { foreach ($this->fields as $field) { $field->setObject($object); $field->readValueFromObject($object); } $keys = array(); foreach ($this->fields as $field) { if ($field->shouldEnableForRole(PhabricatorCustomField::ROLE_STORAGE)) { $keys[$field->getFieldIndex()] = $field; } } if (!$keys) { return $this; } // NOTE: We assume all fields share the same storage. This isn't guaranteed // to be true, but always is for now. $table = head($keys)->newStorageObject(); $objects = array(); if ($object->getPHID()) { $objects = $table->loadAllWhere( 'objectPHID = %s AND fieldIndex IN (%Ls)', $object->getPHID(), array_keys($keys)); $objects = mpull($objects, null, 'getFieldIndex'); } foreach ($keys as $key => $field) { $storage = idx($objects, $key); if ($storage) { $field->setValueFromStorage($storage->getFieldValue()); } else if ($object->getPHID()) { // NOTE: We set this only if the object exists. Otherwise, we allow the // field to retain any default value it may have. $field->setValueFromStorage(null); } } return $this; } public function appendFieldsToForm(AphrontFormView $form) { $enabled = array(); foreach ($this->fields as $field) { if ($field->shouldEnableForRole(PhabricatorCustomField::ROLE_EDIT)) { $enabled[] = $field; } } $phids = array(); foreach ($enabled as $field_key => $field) { $phids[$field_key] = $field->getRequiredHandlePHIDsForEdit(); } $all_phids = array_mergev($phids); if ($all_phids) { $handles = id(new PhabricatorHandleQuery()) ->setViewer($this->viewer) ->withPHIDs($all_phids) ->execute(); } else { $handles = array(); } foreach ($enabled as $field_key => $field) { $field_handles = array_select_keys($handles, $phids[$field_key]); $instructions = $field->getInstructionsForEdit(); if (strlen($instructions)) { $form->appendRemarkupInstructions($instructions); } $form->appendChild($field->renderEditControl($field_handles)); } } public function appendFieldsToPropertyList( PhabricatorCustomFieldInterface $object, PhabricatorUser $viewer, PHUIPropertyListView $view) { $this->readFieldsFromStorage($object); $fields = $this->fields; foreach ($fields as $field) { $field->setViewer($viewer); } // Move all the blocks to the end, regardless of their configuration order, // because it always looks silly to render a block in the middle of a list // of properties. $head = array(); $tail = array(); foreach ($fields as $key => $field) { $style = $field->getStyleForPropertyView(); switch ($style) { case 'property': case 'header': $head[$key] = $field; break; case 'block': $tail[$key] = $field; break; default: throw new Exception( "Unknown field property view style '{$style}'; valid styles are ". "'block' and 'property'."); } } $fields = $head + $tail; $add_header = null; $phids = array(); foreach ($fields as $key => $field) { $phids[$key] = $field->getRequiredHandlePHIDsForPropertyView(); } $all_phids = array_mergev($phids); if ($all_phids) { $handles = id(new PhabricatorHandleQuery()) ->setViewer($viewer) ->withPHIDs($all_phids) ->execute(); } else { $handles = array(); } foreach ($fields as $key => $field) { $field_handles = array_select_keys($handles, $phids[$key]); $label = $field->renderPropertyViewLabel(); $value = $field->renderPropertyViewValue($field_handles); if ($value !== null) { switch ($field->getStyleForPropertyView()) { case 'header': // We want to hide headers if the fields the're assciated with // don't actually produce any visible properties. For example, in a // list like this: // // Header A // Prop A: Value A // Header B // Prop B: Value B // // ...if the "Prop A" field returns `null` when rendering its // property value and we rendered naively, we'd get this: // // Header A // Header B // Prop B: Value B // // This is silly. Instead, we hide "Header A". $add_header = $value; break; case 'property': if ($add_header !== null) { // Add the most recently seen header. $view->addSectionHeader($add_header); $add_header = null; } $view->addProperty($label, $value); break; case 'block': $icon = $field->getIconForPropertyView(); $view->invokeWillRenderEvent(); if ($label !== null) { $view->addSectionHeader($label, $icon); } $view->addTextContent($value); break; } } } } public function buildFieldTransactionsFromRequest( PhabricatorApplicationTransaction $template, AphrontRequest $request) { $xactions = array(); $role = PhabricatorCustomField::ROLE_APPLICATIONTRANSACTIONS; foreach ($this->fields as $field) { if (!$field->shouldEnableForRole($role)) { continue; } $transaction_type = $field->getApplicationTransactionType(); $xaction = id(clone $template) ->setTransactionType($transaction_type); if ($transaction_type == PhabricatorTransactions::TYPE_CUSTOMFIELD) { // For TYPE_CUSTOMFIELD transactions only, we provide the old value // as an input. $old_value = $field->getOldValueForApplicationTransactions(); $xaction->setOldValue($old_value); } $field->readValueFromRequest($request); $xaction ->setNewValue($field->getNewValueForApplicationTransactions()); if ($transaction_type == PhabricatorTransactions::TYPE_CUSTOMFIELD) { // For TYPE_CUSTOMFIELD transactions, add the field key in metadata. $xaction->setMetadataValue('customfield:key', $field->getFieldKey()); } $metadata = $field->getApplicationTransactionMetadata(); foreach ($metadata as $key => $value) { $xaction->setMetadataValue($key, $value); } $xactions[] = $xaction; } return $xactions; } /** * Publish field indexes into index tables, so ApplicationSearch can search * them. * * @return void */ public function rebuildIndexes(PhabricatorCustomFieldInterface $object) { $indexes = array(); $index_keys = array(); $phid = $object->getPHID(); $role = PhabricatorCustomField::ROLE_APPLICATIONSEARCH; foreach ($this->fields as $field) { if (!$field->shouldEnableForRole($role)) { continue; } $index_keys[$field->getFieldIndex()] = true; foreach ($field->buildFieldIndexes() as $index) { $index->setObjectPHID($phid); $indexes[$index->getTableName()][] = $index; } } if (!$indexes) { return; } $any_index = head(head($indexes)); $conn_w = $any_index->establishConnection('w'); foreach ($indexes as $table => $index_list) { $sql = array(); foreach ($index_list as $index) { $sql[] = $index->formatForInsert($conn_w); } $indexes[$table] = $sql; } $any_index->openTransaction(); foreach ($indexes as $table => $sql_list) { queryfx( $conn_w, 'DELETE FROM %T WHERE objectPHID = %s AND indexKey IN (%Ls)', $table, $phid, array_keys($index_keys)); if (!$sql_list) { continue; } foreach (PhabricatorLiskDAO::chunkSQL($sql_list) as $chunk) { queryfx( $conn_w, 'INSERT INTO %T (objectPHID, indexKey, indexValue) VALUES %Q', $table, $chunk); } } $any_index->saveTransaction(); } public function updateAbstractDocument( PhabricatorSearchAbstractDocument $document) { $role = PhabricatorCustomField::ROLE_GLOBALSEARCH; foreach ($this->getFields() as $field) { if (!$field->shouldEnableForRole($role)) { continue; } $field->updateAbstractDocument($document); } - - return $document; } } diff --git a/src/infrastructure/customfield/standard/PhabricatorStandardCustomField.php b/src/infrastructure/customfield/standard/PhabricatorStandardCustomField.php index c2750b89a3..8400500d92 100644 --- a/src/infrastructure/customfield/standard/PhabricatorStandardCustomField.php +++ b/src/infrastructure/customfield/standard/PhabricatorStandardCustomField.php @@ -1,405 +1,426 @@ setAncestorClass(__CLASS__) ->loadObjects(); $types = mpull($types, null, 'getFieldType'); $fields = array(); foreach ($config as $key => $value) { $type = idx($value, 'type', 'text'); if (empty($types[$type])) { // TODO: We should have better typechecking somewhere, and then make // this more serious. continue; } $namespace = $template->getStandardCustomFieldNamespace(); $full_key = "std:{$namespace}:{$key}"; $template = clone $template; $standard = id(clone $types[$type]) ->setRawStandardFieldKey($key) ->setFieldKey($full_key) ->setFieldConfig($value) ->setApplicationField($template); $field = $template->setProxy($standard); $fields[] = $field; } return $fields; } public function setApplicationField( PhabricatorStandardCustomFieldInterface $application_field) { $this->applicationField = $application_field; return $this; } public function getApplicationField() { return $this->applicationField; } public function setFieldName($name) { $this->fieldName = $name; return $this; } public function getFieldValue() { return $this->fieldValue; } public function setFieldValue($value) { $this->fieldValue = $value; return $this; } public function setCaption($caption) { $this->caption = $caption; return $this; } public function getCaption() { return $this->caption; } public function setFieldDescription($description) { $this->fieldDescription = $description; return $this; } public function setFieldConfig(array $config) { foreach ($config as $key => $value) { switch ($key) { case 'name': $this->setFieldName($value); break; case 'description': $this->setFieldDescription($value); break; case 'strings': $this->setStrings($value); break; case 'caption': $this->setCaption($value); break; case 'required': if ($value) { $this->setRequired($value); $this->setFieldError(true); } break; case 'default': $this->setFieldValue($value); break; case 'type': // We set this earlier on. break; } } $this->fieldConfig = $config; return $this; } public function getFieldConfigValue($key, $default = null) { return idx($this->fieldConfig, $key, $default); } public function setFieldError($field_error) { $this->fieldError = $field_error; return $this; } public function getFieldError() { return $this->fieldError; } public function setRequired($required) { $this->required = $required; return $this; } public function getRequired() { return $this->required; } public function setRawStandardFieldKey($raw_key) { $this->rawKey = $raw_key; return $this; } public function getRawStandardFieldKey() { return $this->rawKey; } /* -( PhabricatorCustomField )--------------------------------------------- */ public function setFieldKey($field_key) { $this->fieldKey = $field_key; return $this; } public function getFieldKey() { return $this->fieldKey; } public function getFieldName() { return coalesce($this->fieldName, parent::getFieldName()); } public function getFieldDescription() { return coalesce($this->fieldDescription, parent::getFieldDescription()); } public function setStrings(array $strings) { $this->strings = $strings; return; } public function getString($key, $default = null) { return idx($this->strings, $key, $default); } public function shouldUseStorage() { return true; } public function getValueForStorage() { return $this->getFieldValue(); } public function setValueFromStorage($value) { return $this->setFieldValue($value); } public function shouldAppearInApplicationTransactions() { return true; } public function shouldAppearInEditView() { return $this->getFieldConfigValue('edit', true); } public function readValueFromRequest(AphrontRequest $request) { $value = $request->getStr($this->getFieldKey()); if (!strlen($value)) { $value = null; } $this->setFieldValue($value); } public function getInstructionsForEdit() { return $this->getFieldConfigValue('instructions'); } public function getPlaceholder() { return $this->getFieldConfigValue('placeholder', null); } public function renderEditControl(array $handles) { return id(new AphrontFormTextControl()) ->setName($this->getFieldKey()) ->setCaption($this->getCaption()) ->setValue($this->getFieldValue()) ->setError($this->getFieldError()) ->setLabel($this->getFieldName()) ->setPlaceholder($this->getPlaceholder()); } public function newStorageObject() { return $this->getApplicationField()->newStorageObject(); } public function shouldAppearInPropertyView() { return $this->getFieldConfigValue('view', true); } public function renderPropertyViewValue(array $handles) { if (!strlen($this->getFieldValue())) { return null; } return $this->getFieldValue(); } public function shouldAppearInApplicationSearch() { return $this->getFieldConfigValue('search', false); } protected function newStringIndexStorage() { return $this->getApplicationField()->newStringIndexStorage(); } protected function newNumericIndexStorage() { return $this->getApplicationField()->newNumericIndexStorage(); } public function buildFieldIndexes() { return array(); } public function buildOrderIndex() { return null; } public function readApplicationSearchValueFromRequest( PhabricatorApplicationSearchEngine $engine, AphrontRequest $request) { return; } public function applyApplicationSearchConstraintToQuery( PhabricatorApplicationSearchEngine $engine, PhabricatorCursorPagedPolicyAwareQuery $query, $value) { return; } public function appendToApplicationSearchForm( PhabricatorApplicationSearchEngine $engine, AphrontFormView $form, $value, array $handles) { return; } public function validateApplicationTransactions( PhabricatorApplicationTransactionEditor $editor, $type, array $xactions) { $this->setFieldError(null); $errors = parent::validateApplicationTransactions( $editor, $type, $xactions); if ($this->getRequired()) { $value = $this->getOldValueForApplicationTransactions(); $transaction = null; foreach ($xactions as $xaction) { $value = $xaction->getNewValue(); if (!$this->isValueEmpty($value)) { $transaction = $xaction; break; } } if ($this->isValueEmpty($value)) { $error = new PhabricatorApplicationTransactionValidationError( $type, pht('Required'), pht('%s is required.', $this->getFieldName()), $transaction); $error->setIsMissingFieldError(true); $errors[] = $error; $this->setFieldError(pht('Required')); } } return $errors; } protected function isValueEmpty($value) { if (is_array($value)) { return empty($value); } return !strlen($value); } public function getApplicationTransactionTitle( PhabricatorApplicationTransaction $xaction) { $author_phid = $xaction->getAuthorPHID(); $old = $xaction->getOldValue(); $new = $xaction->getNewValue(); if (!$old) { return pht( '%s set %s to %s.', $xaction->renderHandleLink($author_phid), $this->getFieldName(), $new); } else if (!$new) { return pht( '%s removed %s.', $xaction->renderHandleLink($author_phid), $this->getFieldName()); } else { return pht( '%s changed %s from %s to %s.', $xaction->renderHandleLink($author_phid), $this->getFieldName(), $old, $new); } } public function getApplicationTransactionTitleForFeed( PhabricatorApplicationTransaction $xaction, PhabricatorFeedStory $story) { $author_phid = $xaction->getAuthorPHID(); $object_phid = $xaction->getObjectPHID(); $old = $xaction->getOldValue(); $new = $xaction->getNewValue(); if (!$old) { return pht( '%s set %s to %s on %s.', $xaction->renderHandleLink($author_phid), $this->getFieldName(), $new, $xaction->renderHandleLink($object_phid)); } else if (!$new) { return pht( '%s removed %s on %s.', $xaction->renderHandleLink($author_phid), $this->getFieldName(), $xaction->renderHandleLink($object_phid)); } else { return pht( '%s changed %s from %s to %s on %s.', $xaction->renderHandleLink($author_phid), $this->getFieldName(), $old, $new, $xaction->renderHandleLink($object_phid)); } } public function getHeraldFieldValue() { return $this->getFieldValue(); } public function getFieldControlID($key = null) { $key = coalesce($key, $this->getRawStandardFieldKey()); return 'std:control:'.$key; } + public function shouldAppearInGlobalSearch() { + return $this->getFieldConfigValue('fulltext', false); + } + + public function updateAbstractDocument( + PhabricatorSearchAbstractDocument $document) { + + $field_key = $this->getFieldConfigValue('fulltext'); + + // If the caller or configuration didn't specify a valid field key, + // generate one automatically from the field index. + if (!is_string($field_key) || (strlen($field_key) != 4)) { + $field_key = '!'.substr($this->getFieldIndex(), 0, 3); + } + + $field_value = $this->getFieldValue(); + if (strlen($field_value)) { + $document->addField($field_key, $field_value); + } + } + }