浏览代码

Merge pull request #2815 from markstory/3.0-form-helper-pt1

3.0 Start updating FormHelper
José Lorenzo Rodríguez 12 年之前
父节点
当前提交
5e234d48db

+ 239 - 196
src/View/Helper/FormHelper.php

@@ -16,13 +16,22 @@ namespace Cake\View\Helper;
 
 use Cake\Core\Configure;
 use Cake\Error;
+use Cake\ORM\Entity;
 use Cake\ORM\TableRegistry;
 use Cake\Utility\Hash;
 use Cake\Utility\Inflector;
 use Cake\Utility\Security;
+use Cake\View\Form\ArrayContext;
+use Cake\View\Form\ContextInterface;
+use Cake\View\Form\EntityContext;
+use Cake\View\Form\NullContext;
 use Cake\View\Helper;
+use Cake\View\Helper\StringTemplateTrait;
+use Cake\View\StringTemplate;
 use Cake\View\View;
-use \DateTime;
+use Cake\View\Widget\InputRegistry;
+use DateTime;
+use Traversable;
 
 /**
  * Form helper library.
@@ -34,6 +43,8 @@ use \DateTime;
  */
 class FormHelper extends Helper {
 
+	use StringTemplateTrait;
+
 /**
  * Other helpers used by FormHelper
  *
@@ -122,113 +133,94 @@ class FormHelper extends Helper {
 	protected $_domIdSuffixes = array();
 
 /**
- * Copies the validationErrors variable from the View object into this instance
+ * Registry for input widgets.
  *
- * @param View $View The View this helper is being attached to.
- * @param array $settings Configuration settings for the helper.
+ * @var Cake\View\Widget\InputRegistry
  */
-	public function __construct(View $View, $settings = array()) {
-		parent::__construct($View, $settings);
-		$this->validationErrors =& $View->validationErrors;
-	}
+	protected $_registry;
 
 /**
- * Guess the location for a model based on its name and tries to create a new instance
- * or get an already created instance of the model
+ * Context for the current form.
  *
- * @param string $model
- * @return Model model instance
+ * @var Cake\View\Form\Context
  */
-	protected function _getModel($model) {
-		$object = null;
-		if (!$model || $model === 'Model') {
-			return $object;
-		}
+	protected $_context;
 
-		if (array_key_exists($model, $this->_models)) {
-			return $this->_models[$model];
-		}
+/**
+ * Context provider methods.
+ *
+ * @var array
+ * @see addContextProvider
+ */
+	protected $_contextProviders;
 
-		$object = TableRegistry::get($model);
+/**
+ * Default templates the FormHelper uses.
+ *
+ * @var array
+ */
+	protected $_defaultTemplates = [
+		'formstart' => '<form{{attrs}}>',
+		'formend' => '</form>',
+		'hiddenblock' => '<div style="display:none;">{{content}}</div>',
+	];
 
-		$this->_models[$model] = $object;
-		if (!$object) {
-			return null;
-		}
+/**
+ * Copies the validationErrors variable from the View object into this instance
+ *
+ * @param Cake\View\View $View The View this helper is being attached to.
+ * @param array $settings Configuration settings for the helper.
+ */
+	public function __construct(View $View, $settings = array()) {
+		$settings += ['widgets' => [], 'templates' => null, 'registry' => null];
+		parent::__construct($View, $settings);
+
+		$this->initStringTemplates($this->_defaultTemplates);
+		$this->inputRegistry($settings['registry'], $settings['widgets']);
+		unset($this->settings['widgets'], $this->settings['registry']);
 
-		$this->fieldset[$model] = array('fields' => null, 'key' => $object->primaryKey(), 'validates' => null);
-		return $object;
+		$this->_addDefaultContextProviders();
 	}
 
 /**
- * Inspects the model properties to extract information from them.
- * Currently it can extract information from the the fields, the primary key and required fields
- *
- * The $key parameter accepts the following list of values:
- *
- * - key: Returns the name of the primary key for the model
- * - fields: Returns the model schema
- * - validates: returns the list of fields that are required
- * - errors: returns the list of validation errors
+ * Set the input registry the helper will use.
  *
- * If the $field parameter is passed if will return the information for that sole field.
- *
- * `$this->_introspectModel('Post', 'fields', 'title');` will return the schema information for title column
- *
- * @param string $model name of the model to extract information from
- * @param string $key name of the special information key to obtain (key, fields, validates, errors)
- * @param string $field name of the model field to get information from
- * @return mixed information extracted for the special key and field in a model
+ * @param Cake\View\Widget\InputRegistry $instance The registry instance to set.
+ * @param array $widgets An array of widgets
+ * @return Cake\View\Widget\InputRegistry
  */
-	protected function _introspectModel($model, $key, $field = null) {
-		$object = $this->_getModel($model);
-		if (!$object) {
-			return;
-		}
-
-		if ($key === 'key') {
-			return $this->fieldset[$model]['key'] = $object->primaryKey();
-		}
-
-		if ($key === 'fields') {
-			if (!isset($this->fieldset[$model]['fields'])) {
-				$this->fieldset[$model]['fields'] = $object->schema();
-				foreach ($object->hasAndBelongsToMany as $alias => $assocData) {
-					$this->fieldset[$object->alias]['fields'][$alias] = array('type' => 'multiple');
-				}
-			}
-			if ($field === null || $field === false) {
-				return $this->fieldset[$model]['fields'];
-			} elseif (isset($this->fieldset[$model]['fields'][$field])) {
-				return $this->fieldset[$model]['fields'][$field];
+	public function inputRegistry(InputRegistry $instance = null, $widgets = []) {
+		if ($instance === null) {
+			if ($this->_registry === null) {
+				$this->_registry = new InputRegistry($this->_templater, $widgets);
 			}
-			return isset($object->hasAndBelongsToMany[$field]) ? array('type' => 'multiple') : null;
-		}
-
-		if ($key === 'errors' && !isset($this->validationErrors[$model])) {
-			$this->validationErrors[$model] =& $object->validationErrors;
-			return $this->validationErrors[$model];
-		} elseif ($key === 'errors' && isset($this->validationErrors[$model])) {
-			return $this->validationErrors[$model];
+			return $this->_registry;
 		}
+		$this->_registry = $instance;
+		return $this->_registry;
+	}
 
-		if ($key === 'validates' && !isset($this->fieldset[$model]['validates'])) {
-			$validates = array();
-			foreach ($object->validator() as $validateField => $validateProperties) {
-				if ($this->_isRequiredField($validateProperties)) {
-					$validates[$validateField] = true;
-				}
+/**
+ * Add the default suite of context providers provided by CakePHP.
+ *
+ * @return void
+ */
+	protected function _addDefaultContextProviders() {
+		$this->addContextProvider('array', function ($request, $data) {
+			if (is_array($data['entity']) && isset($data['entity']['schema'])) {
+				return new ArrayContext($request, $data['entity']);
 			}
-			$this->fieldset[$model]['validates'] = $validates;
-		}
+		});
 
-		if ($key === 'validates') {
-			if (empty($field)) {
-				return $this->fieldset[$model]['validates'];
+		$this->addContextProvider('orm', function ($request, $data) {
+			if (
+				$data['entity'] instanceof Entity ||
+				$data['entity'] instanceof Traversable ||
+				(is_array($data['entity']) && !isset($data['entity']['schema']))
+			) {
+				return new EntityContext($request, $data);
 			}
-			return isset($this->fieldset[$model]['validates'][$field]) ?
-				$this->fieldset[$model]['validates'] : null;
-		}
+		});
 	}
 
 /**
@@ -268,19 +260,7 @@ class FormHelper extends Helper {
 			array_splice($entity, 1, 0, $model);
 			$model = array_shift($entity);
 		}
-
-		$errors = array();
-		if (!empty($entity) && isset($this->validationErrors[$model])) {
-			$errors = $this->validationErrors[$model];
-		}
-		if (!empty($entity) && empty($errors)) {
-			$errors = $this->_introspectModel($model, 'errors');
-		}
-		if (empty($errors)) {
-			return false;
-		}
-		$errors = Hash::get($errors, implode('.', $entity));
-		return $errors === null ? false : $errors;
+		return false;
 	}
 
 /**
@@ -288,109 +268,48 @@ class FormHelper extends Helper {
  *
  * ### Options:
  *
- * - `type` Form method defaults to POST
- * - `action`  The controller action the form submits to, (optional).
- * - `url`  The URL the form submits to. Can be a string or a URL array. If you use 'url'
+ * - `type` Form method defaults to autodetecting based on the form context. If
+ *   the form context's isCreate() method returns false, a PUT request will be done.
+ * - `action` The controller action the form submits to, (optional). Use this option if you
+ *   don't need to change the controller from the current request's controller.
+ * - `url` The URL the form submits to. Can be a string or a URL array. If you use 'url'
  *    you should leave 'action' undefined.
- * - `default`  Allows for the creation of Ajax forms. Set this to false to prevent the default event handler.
+ * - `default` Allows for the creation of Ajax forms. Set this to false to prevent the default event handler.
  *   Will create an onsubmit attribute if it doesn't not exist. If it does, default action suppression
  *   will be appended.
  * - `onsubmit` Used in conjunction with 'default' to create ajax forms.
- * - `inputDefaults` set the default $options for FormHelper::input(). Any options that would
- *   be set when using FormHelper::input() can be set here. Options set with `inputDefaults`
- *   can be overridden when calling input()
  * - `encoding` Set the accept-charset encoding for the form. Defaults to `Configure::read('App.encoding')`
+ * - `context` Additional options for the context class. For example the EntityContext accepts a 'table'
+ *   option that allows you to set the specific Table class the form should be based on.
  *
- * @param mixed $model The model name for which the form is being defined. Should
- *   include the plugin name for plugin models. e.g. `ContactManager.Contact`.
- *   If an array is passed and $options argument is empty, the array will be used as options.
- *   If `false` no model is used.
+ * @param mixed $model The context for which the form is being defined. Can
+ *   be an ORM entity, ORM resultset, or an array of meta data. You can use false or null
+ *   to make a model-less form.
  * @param array $options An array of html attributes and options.
  * @return string An formatted opening FORM tag.
  * @link http://book.cakephp.org/2.0/en/core-libraries/helpers/form.html#options-for-create
  */
-	public function create($model = null, $options = array()) {
-		$created = $id = false;
+	public function create($model = null, $options = []) {
 		$append = '';
 
-		if (is_array($model) && empty($options)) {
-			$options = $model;
-			$model = null;
+		if (empty($options['context'])) {
+			$options['context'] = [];
 		}
+		$options['context']['entity'] = $model;
+		$this->_context = $this->_buildContext($options['context']);
+		unset($options['context']);
 
-		if (empty($model) && $model !== false && !empty($this->request->params['models'])) {
-			$model = key($this->request->params['models']);
-		} elseif (empty($model) && empty($this->request->params['models'])) {
-			$model = false;
-		}
-		$this->defaultModel = $model;
-
-		$key = null;
-		if ($model !== false) {
-			list($plugin, $model) = pluginSplit($model, true);
-			$key = $this->_introspectModel($plugin . $model, 'key');
-			$this->setEntity($model, true);
-		}
-
-		if ($model !== false && $key) {
-			$recordExists = (
-				isset($this->request->data[$model]) &&
-				!empty($this->request->data[$model][$key]) &&
-				!is_array($this->request->data[$model][$key])
-			);
-
-			if ($recordExists) {
-				$created = true;
-				$id = $this->request->data[$model][$key];
-			}
-		}
+		$isCreate = $this->_context->isCreate();
 
-		$options = array_merge(array(
-			'type' => ($created && empty($options['action'])) ? 'put' : 'post',
+		$options = $options + [
+			'type' => $isCreate ? 'post' : 'put',
 			'action' => null,
 			'url' => null,
 			'default' => true,
 			'encoding' => strtolower(Configure::read('App.encoding')),
-			'inputDefaults' => array()),
-		$options);
-		$this->inputDefaults($options['inputDefaults']);
-		unset($options['inputDefaults']);
+		];
 
-		if (!isset($options['id'])) {
-			$domId = isset($options['action']) ? $options['action'] : $this->request['action'];
-			$options['id'] = $this->domId($domId . 'Form');
-		}
-
-		if ($options['action'] === null && $options['url'] === null) {
-			$options['action'] = $this->request->here(false);
-		} elseif (empty($options['url']) || is_array($options['url'])) {
-			if (empty($options['url']['controller'])) {
-				if (!empty($model)) {
-					$options['url']['controller'] = Inflector::underscore(Inflector::pluralize($model));
-				} elseif (!empty($this->request->params['controller'])) {
-					$options['url']['controller'] = Inflector::underscore($this->request->params['controller']);
-				}
-			}
-			if (empty($options['action'])) {
-				$options['action'] = $this->request->params['action'];
-			}
-
-			$plugin = null;
-			if ($this->plugin) {
-				$plugin = Inflector::underscore($this->plugin);
-			}
-			$actionDefaults = array(
-				'plugin' => $plugin,
-				'controller' => $this->_View->viewPath,
-				'action' => $options['action'],
-			);
-			$options['action'] = array_merge($actionDefaults, (array)$options['url']);
-			if (empty($options['action'][0]) && !empty($id)) {
-				$options['action'][0] = $id;
-			}
-		} elseif (is_string($options['url'])) {
-			$options['action'] = $options['url'];
-		}
+		$options['action'] = $this->_formUrl($options);
 		unset($options['url']);
 
 		switch (strtolower($options['type'])) {
@@ -399,12 +318,15 @@ class FormHelper extends Helper {
 				break;
 			case 'file':
 				$htmlAttributes['enctype'] = 'multipart/form-data';
-				$options['type'] = ($created) ? 'put' : 'post';
+				$options['type'] = ($isCreate) ? 'post' : 'put';
 			case 'post':
 			case 'put':
 			case 'delete':
+			case 'patch':
 				$append .= $this->hidden('_method', array(
-					'name' => '_method', 'value' => strtoupper($options['type']), 'id' => null,
+					'name' => '_method',
+					'value' => strtoupper($options['type']),
+					'id' => null,
 					'secure' => static::SECURE_SKIP
 				));
 			default:
@@ -412,8 +334,7 @@ class FormHelper extends Helper {
 		}
 		$this->requestType = strtolower($options['type']);
 
-		$action = $this->url($options['action']);
-		unset($options['type'], $options['action']);
+		$htmlAttributes['action'] = $this->url($options['action']);
 
 		if (!$options['default']) {
 			if (!isset($options['onsubmit'])) {
@@ -421,12 +342,11 @@ class FormHelper extends Helper {
 			}
 			$htmlAttributes['onsubmit'] = $options['onsubmit'] . 'event.returnValue = false; return false;';
 		}
-		unset($options['default']);
 
 		if (!empty($options['encoding'])) {
 			$htmlAttributes['accept-charset'] = $options['encoding'];
-			unset($options['encoding']);
 		}
+		unset($options['type'], $options['action'], $options['encoding'], $options['default']);
 
 		$htmlAttributes = array_merge($options, $htmlAttributes);
 
@@ -436,14 +356,49 @@ class FormHelper extends Helper {
 		}
 
 		if (!empty($append)) {
-			$append = $this->Html->useTag('hiddenblock', $append);
+			$append = $this->formatTemplate('hiddenblock', ['content' => $append]);
 		}
+		return $this->formatTemplate('formstart', [
+			'attrs' => $this->_templater->formatAttributes($htmlAttributes)
+		]) . $append;
+	}
 
-		if ($model !== false) {
-			$this->setEntity($model, true);
-			$this->_introspectModel($model, 'fields');
+/**
+ * Create the URL for a form based on the options.
+ *
+ * @param array $options An array of options from create()
+ * @return string The action attribute for the form.
+ */
+	protected function _formUrl($options) {
+		if ($options['action'] === null && $options['url'] === null) {
+			return $this->request->here(false);
+		}
+		if (empty($options['url']) || is_array($options['url'])) {
+			if (isset($options['action']) && empty($options['url']['action'])) {
+				$options['url']['action'] = $options['action'];
+			}
+
+			$plugin = $this->plugin ? Inflector::underscore($this->plugin) : null;
+			$actionDefaults = [
+				'plugin' => $plugin,
+				'controller' => Inflector::underscore($this->request->params['controller']),
+				'action' => $this->request->params['action'],
+			];
+
+			$action = (array)$options['url'] + $actionDefaults;
+
+			$pk = $this->_context->primaryKey();
+			if (count($pk)) {
+				$id = $this->_context->val($pk[0]);
+			}
+			if (empty($action[0]) && isset($id)) {
+				$action[0] = $id;
+			}
+			return $action;
+		}
+		if (is_string($options['url'])) {
+			return $options['url'];
 		}
-		return $this->Html->useTag('form', $action, $htmlAttributes) . $append;
 	}
 
 /**
@@ -513,11 +468,10 @@ class FormHelper extends Helper {
 			$out .= $this->secure($this->fields);
 			$this->fields = array();
 		}
-		$this->setEntity(null);
 		$out .= $this->Html->useTag('formend');
 
-		$this->_View->modelScope = false;
 		$this->requestType = null;
+		$this->_context = null;
 		return $out;
 	}
 
@@ -2942,4 +2896,93 @@ class FormHelper extends Helper {
 		return $this->_inputDefaults;
 	}
 
+/**
+ * Add a new context type.
+ *
+ * Form context types allow FormHelper to interact with
+ * data providers that come from outside CakePHP. For example
+ * if you wanted to use an alternative ORM like Doctrine you could
+ * create and connect a new context class to allow FormHelper to
+ * read metadata from doctrine.
+ *
+ * @param string $type The type of context. This key
+ *   can be used to overwrite existing providers.
+ * @param callable $check A callable that returns a object
+ *   when the form context is the correct type.
+ * @return void
+ */
+	public function addContextProvider($name, callable $check) {
+		$this->_contextProviders[$name] = $check;
+	}
+
+/**
+ * Get the context instance for the current form set.
+ *
+ * If there is no active form null will be returned.
+ *
+ * @return null|Cake\View\Form\ContextInterface The context for the form.
+ */
+	public function context() {
+		return $this->_context;
+	}
+
+/**
+ * Find the matching context provider for the data.
+ *
+ * If no type can be matched a NullContext will be returned.
+ *
+ * @param mixed $data The data to get a context provider for.
+ * @return mixed Context provider.
+ * @throws RuntimeException when the context class does not implement the
+ *   ContextInterface.
+ */
+	protected function _buildContext($data) {
+		foreach ($this->_contextProviders as $key => $check) {
+			$context = $check($this->request, $data);
+			if ($context) {
+				break;
+			}
+		}
+		if (!isset($context)) {
+			$context = new NullContext($this->request, $data);
+		}
+		if (!($context instanceof ContextInterface)) {
+			throw new \RuntimeException(
+				'Context objects must implement Cake\View\Form\ContextInterface'
+			);
+		}
+		return $context;
+	}
+
+/**
+ * Add a new widget to FormHelper.
+ *
+ * Allows you to add or replace widget instances with custom code.
+ *
+ * @param string $name The name of the widget. e.g. 'text'.
+ * @param array|WidgetInterface Either a string class name or an object
+ *    implementing the WidgetInterface.
+ * @return void
+ */
+	public function addWidget($name, $spec) {
+		$this->_registry->add([$name => $spec]);
+	}
+
+/**
+ * Render a named widget.
+ *
+ * This is a lower level method. For built-in widgets, you should be using
+ * methods like `text`, `hidden`, and `radio`. If you are using additional
+ * widgets you should use this method render the widget without the label
+ * or wrapping div.
+ *
+ * @param string $name The name of the widget. e.g. 'text'.
+ * @param array $attrs The attributes for rendering the input.
+ * @return void
+ */
+	public function widget($name, array $data = []) {
+		$widget = $this->_registry->get($name);
+		return $widget->render($data);
+	}
+
 }

+ 12 - 2
src/View/Widget/InputRegistry.php

@@ -136,15 +136,25 @@ class InputRegistry {
  *   implement WidgetInterface.
  */
 	protected function _resolveWidget($widget) {
-		if (is_object($widget)) {
+		$type = gettype($widget);
+		if ($type === 'object' && $widget instanceof WidgetInterface) {
 			return $widget;
 		}
+		if ($type === 'object') {
+			throw new \RuntimeException(
+				'Input objects must implement Cake\View\Widget\WidgetInterface.'
+			);
+		}
+		if ($type === 'string') {
+			$widget = [$widget];
+		}
+
 		$class = array_shift($widget);
 		$className = App::classname($class, 'View/Input');
 		if ($className === false || !class_exists($className)) {
 			throw new \RuntimeException(sprintf('Unable to locate widget class "%s"', $class));
 		}
-		if (count($widget)) {
+		if ($type === 'array' && count($widget)) {
 			$reflection = new ReflectionClass($className);
 			$arguments = [$this->_templates];
 			foreach ($widget as $requirement) {

文件差异内容过多而无法显示
+ 6023 - 5916
tests/TestCase/View/Helper/FormHelperTest.php


+ 25 - 0
tests/TestCase/View/Widget/InputRegistryTest.php

@@ -58,6 +58,31 @@ class InputRegistryTestCase extends TestCase {
 			'text' => ['Cake\View\Widget\Basic'],
 		]);
 		$this->assertNull($result);
+		$result = $inputs->get('text');
+		$this->assertInstanceOf('Cake\View\Widget\WidgetInterface', $result);
+
+		$inputs = new InputRegistry($this->templates);
+		$result = $inputs->add([
+			'hidden' => 'Cake\View\Widget\Basic',
+		]);
+		$this->assertNull($result);
+		$result = $inputs->get('hidden');
+		$this->assertInstanceOf('Cake\View\Widget\WidgetInterface', $result);
+	}
+
+/**
+ * Test adding an instance of an invalid type.
+ *
+ * @expectedException \RuntimeException
+ * @expectedExceptionMessage Input objects must implement Cake\View\Widget\WidgetInterface
+ * @return void
+ */
+	public function testAddInvalidType() {
+		$inputs = new InputRegistry($this->templates);
+		$inputs->add([
+			'text' => new \StdClass()
+		]);
+		$inputs->get('text');
 	}
 
 /**

+ 0 - 5
tests/test_app/TestApp/Template/Posts/cache_form.ctp

@@ -3,11 +3,6 @@
 	<?= $this->Form->create(false); ?>
 		<fieldset>
 			<legend><?= __('Add User'); ?></legend>
-		<?php
-			echo $this->Form->input('username');
-			echo $this->Form->input('email');
-			echo $this->Form->input('password');
-		?>
 		</fieldset>
 	<?= $this->Form->end('Submit'); ?>
 <!--/nocache-->