diff --git a/src/Blast/Component/View/Builder/ViewBuilder.php b/src/Blast/Component/View/Builder/ViewBuilder.php new file mode 100644 index 00000000..948833e0 --- /dev/null +++ b/src/Blast/Component/View/Builder/ViewBuilder.php @@ -0,0 +1,302 @@ + + */ +class ViewBuilder implements \IteratorAggregate, ViewBuilderInterface +{ + /** + * @var ViewConfigInterface + */ + private $config; + + /** + * @var FormFactoryInterface + */ + private $viewFactory; + + protected $unresolvedChildren = []; + protected $children = []; + + public function __construct(?string $name, ?string $dataClass, ViewFactoryInterface $factory, array $options = array()) + { + self::validateName($name); + if (null !== $dataClass && !class_exists($dataClass) && !interface_exists($dataClass)) { + throw new \InvalidArgumentException(sprintf('Class "%s" not found. Is the "data_class" view option set correctly?', $dataClass)); + } + $this->config = new ViewConfig($name, $dataClass, $options); + $this->setViewFactory($factory); + } + + public function add($child, $type = null, array $options = []) + { + $this->children[$child] = null; + $this->unresolvedChildren[$child] = array( + 'type' => $type, + 'options' => $options, + ); + + return $this; + } + + public function create($name, $type = null, array $options = array()) + { + if (null === $type && null === $this->getDataClass()) { + $type = 'TextType'; + } + if (null !== $type) { + return $this->getViewFactory()->createNamedBuilder($name, $type, null, $options); + } + + return $this->getViewFactory()->createBuilderForProperty($this->getDataClass(), $name, null, $options); + } + + public function get($name) + { + if (isset($this->unresolvedChildren[$name])) { + return $this->resolveChild($name); + } + if (isset($this->children[$name])) { + return $this->children[$name]; + } + throw new InvalidArgumentException(sprintf('The child with the name "%s" does not exist.', $name)); + } + + public function remove($name) + { + unset($this->unresolvedChildren[$name], $this->children[$name]); + + return $this; + } + + public function has($name) + { + if (isset($this->unresolvedChildren[$name])) { + return true; + } + if (isset($this->children[$name])) { + return true; + } + + return false; + } + + public function all() + { + $this->resolveChildren(); + + return $this->children; + } + + public function count() + { + return count($this->children); + } + + public function getIterator() + { + return new \ArrayIterator($this->all()); + } + + public function getView() + { + $this->resolveChildren(); + + $view = new View($this->getViewConfig()); + + foreach ($this->children as $child) { + $view->add($child->getView()); + } + + return $view; + } + + private function resolveChildren() + { + foreach ($this->unresolvedChildren as $name => $info) { + $this->children[$name] = $this->create($name, $info['type'], $info['options']); + } + $this->unresolvedChildren = array(); + } + + private function resolveChild(string $name): self + { + $info = $this->unresolvedChildren[$name]; + $child = $this->create($name, $info['type'], $info['options']); + $this->children[$name] = $child; + unset($this->unresolvedChildren[$name]); + + return $child; + } + + public function setViewFactory(ViewFactoryInterface $viewFactory) + { + $this->viewFactory = $viewFactory; + + return $this; + } + + public function getViewFactory(): ViewFactoryInterface + { + return $this->viewFactory; + } + + public function setType(ViewTypeInterface $type) + { + $this->config->setType($type); + + return $this; + } + + public function setMapped(bool $mapped) + { + $this->config->setMapped($mapped); + + return $this; + } + + public function setPropertyPath($propertyPath) + { + if (null !== $propertyPath && !$propertyPath instanceof PropertyPathInterface) { + $propertyPath = new PropertyPath($propertyPath); + } + $this->propertyPath = $propertyPath; + + return $this; + } + + public function setDataMapper(DataMapperInterface $dataMapper = null) + { + $this->config->setDataMapper($dataMapper); + + return $this; + } + + public function setData($data) + { + $this->config->setData($data); + + return $this; + } + + public function setInheritData($inheritData) + { + $this->config->setInheritData($inheritData); + + return $this; + } + + public function setCompound($compound) + { + $this->config->setCompound($compound); + + return $this; + } + + public function setDataLocked($locked) + { + $this->config->setDataLocked($locked); + + return $this; + } + + public function setCondition(?callable $condition) + { + $this->config->setCondition($condition); + + return $this; + } + + public function addDataTransformer(DataTransformerInterface $dataTransformer, $forcePrepend = false) + { + $this->config->addDataTransformer($dataTransformer, $forcePrepend); + } + + /** + * {@inheritdoc} + */ + public function getOptions() + { + return $this->config->getOptions(); + } + + /** + * {@inheritdoc} + */ + public function hasOption($name) + { + return $this->config->hasOption($name); + } + + /** + * {@inheritdoc} + */ + public function getOption($name, $default = null) + { + return $this->config->getOption($name, $default); + } + + /** + * {@inheritdoc} + */ + public function setAttribute($name, $value) + { + $this->config->setAttribute($name, $value); + + return $this; + } + + /** + * {@inheritdoc} + */ + public function setAttributes(array $attributes) + { + $this->config->setAttributes($attributes); + + return $this; + } + + public function getViewConfig() + { + return $this->config; + } + + public static function validateName($name) + { + if (null !== $name && !is_string($name) && !is_int($name)) { + throw new UnexpectedTypeException($name, 'string, integer or null'); + } + if (!self::isValidName($name)) { + throw new \InvalidArgumentException(sprintf( + 'The name "%s" contains illegal characters. Names should start with a letter, digit or underscore and only contain letters, digits, numbers, underscores ("_"), hyphens ("-") and colons (":").', + $name + )); + } + } + + public static function isValidName($name) + { + return '' === $name || null === $name || preg_match('/^[a-zA-Z0-9_][a-zA-Z0-9_\-:]*$/D', $name); + } +} diff --git a/src/Blast/Component/View/Builder/ViewBuilderInterface.php b/src/Blast/Component/View/Builder/ViewBuilderInterface.php new file mode 100644 index 00000000..d8c5ed90 --- /dev/null +++ b/src/Blast/Component/View/Builder/ViewBuilderInterface.php @@ -0,0 +1,75 @@ + + */ +interface ViewBuilderInterface extends \Traversable, \Countable +{ + public function add($child, $type = null, array $options = array()); + + /** + * Creates a view builder. + * + * @param string $name The name of the view or the name of the property + * @param string|null $type The type of the view or null if name is a property + * @param array $options The options + * + * @return self + */ + public function create($name, $type = null, array $options = array()); + + /** + * Returns a child by name. + * + * @param string $name The name of the child + * + * @return self + * + * @throws Exception\InvalidArgumentException if the given child does not exist + */ + public function get($name); + + /** + * Removes the field with the given name. + * + * @param string $name + * + * @return self + */ + public function remove($name); + + /** + * Returns whether a field with the given name exists. + * + * @param string $name + * + * @return bool + */ + public function has($name); + + /** + * Returns the children. + * + * @return array + */ + public function all(); + + /** + * Creates the view. + * + * @return ViewInterface The view + */ + public function getView(); +} diff --git a/src/Blast/Component/View/Config/ViewConfig.php b/src/Blast/Component/View/Config/ViewConfig.php new file mode 100644 index 00000000..1fb7c86c --- /dev/null +++ b/src/Blast/Component/View/Config/ViewConfig.php @@ -0,0 +1,284 @@ + + */ +class ViewConfig implements ViewConfigInterface +{ + /** + * @var string + */ + private $name; + /** + * @var PropertyPathInterface + */ + private $propertyPath; + /** + * @var bool + */ + private $compound = false; + + /** + * @var callable + */ + private $condition; + /** + * @var ViewTypeInterface + */ + private $type; + /** + * @var string + */ + private $dataClass; + + /** + * @var array + */ + private $dataTransformers = array(); + + /** + * @var DataMapperInterface + */ + private $dataMapper; + /** + * @var bool + */ + private $inheritData = false; + /** + * @var bool + */ + private $dataLocked; + /** + * @var mixed + */ + private $emptyData; + /** + * @var array + */ + private $attributes = array(); + /** + * @var mixed + */ + private $data; + /** + * @var bool + */ + private $mapped = true; + /** + * @var array + */ + private $options; + + public function __construct(?string $name, ?string $dataClass, array $options) + { + $this->name = $name; + $this->dataClass = $dataClass; + $this->options = $options; + } + + public function getName() + { + return $this->name; + } + + public function getDataClass() + { + return $this->dataClass; + } + + public function getEventDispatcher() + { + return $this->dispatcher; + } + + public function getDataTransformers() + { + return $this->dataTransformers; + } + + public function addDataTransformer(DataTransformerInterface $dataTransformer, $forcePrepend = false) + { + if ($forcePrepend) { + array_unshift($this->dataTransformers, $dataTransformer); + } else { + $this->dataTransformers[] = $dataTransformer; + } + } + + public function getDataMapper() + { + return $this->dataMapper; + } + + public function getPropertyPath() + { + return $this->propertyPath; + } + + public function getInheritData() + { + return $this->inheritData; + } + + public function getType() + { + return $this->type; + } + + public function getEmptyData() + { + return $this->emptyData; + } + + /** + * {@inheritdoc} + */ + public function isMapped() + { + return $this->mapped; + } + + public function setMapped(bool $mapped) + { + $this->mapped = $mapped; + } + + public function getAttributes() + { + return $this->attributes; + } + + public function hasAttribute($name) + { + return array_key_exists($name, $this->attributes); + } + + /** + * {@inheritdoc} + */ + public function getAttribute($name, $default = null) + { + return array_key_exists($name, $this->attributes) ? $this->attributes[$name] : $default; + } + + /** + * {@inheritdoc} + */ + public function getData() + { + return $this->data; + } + + public function getDataLocked() + { + return $this->dataLocked; + } + + public function getCompound() + { + return $this->compound; + } + + public function getOptions() + { + return $this->options; + } + + /** + * {@inheritdoc} + */ + public function hasOption($name) + { + return array_key_exists($name, $this->options); + } + + /** + * {@inheritdoc} + */ + public function getOption($name, $default = null) + { + return array_key_exists($name, $this->options) ? $this->options[$name] : $default; + } + + /** + * {@inheritdoc} + */ + public function setAttribute($name, $value) + { + $this->attributes[$name] = $value; + } + + /** + * {@inheritdoc} + */ + public function setAttributes(array $attributes) + { + $this->attributes = $attributes; + } + + public function setDataMapper(DataMapperInterface $dataMapper = null) + { + $this->dataMapper = $dataMapper; + } + + public function setData($data) + { + $this->data = $data; + } + + public function setName($name) + { + $this->name = $name; + } + + public function setType(ViewTypeInterface $type) + { + $this->type = $type; + } + + public function setInheritData($inheritData) + { + $this->inheritData = $inheritData; + } + + public function setCondition(?callable $condition) + { + $this->condition = $condition; + } + + public function checkCondition($data) + { + return ($this->condition)($data); + } + + public function hasCondition() + { + return null !== $this->condition; + } + + public function setCompound($compound) + { + $this->compound = $compound; + } + + public function setDataLocked($locked) + { + $this->dataLocked = $locked; + } +} diff --git a/src/Blast/Component/View/Config/ViewConfigInterface.php b/src/Blast/Component/View/Config/ViewConfigInterface.php new file mode 100644 index 00000000..67c5f44a --- /dev/null +++ b/src/Blast/Component/View/Config/ViewConfigInterface.php @@ -0,0 +1,20 @@ + + */ +interface ViewConfigInterface +{ +} diff --git a/src/Blast/Component/View/DataMapper/DataMapperInterface.php b/src/Blast/Component/View/DataMapper/DataMapperInterface.php new file mode 100644 index 00000000..dbea7f67 --- /dev/null +++ b/src/Blast/Component/View/DataMapper/DataMapperInterface.php @@ -0,0 +1,23 @@ + + */ + interface DataMapperInterface + { + public function mapDataToViews($data, $views); + + public function mapViewsToData($views, &$data); + } diff --git a/src/Blast/Component/View/DataMapper/PropertyPathMapper.php b/src/Blast/Component/View/DataMapper/PropertyPathMapper.php new file mode 100644 index 00000000..7bfe3586 --- /dev/null +++ b/src/Blast/Component/View/DataMapper/PropertyPathMapper.php @@ -0,0 +1,80 @@ + + */ + class PropertyPathMapper implements DataMapperInterface + { + private $propertyAccessor; + + public function __construct(PropertyAccessorInterface $propertyAccessor = null) + { + $this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor(); + } + + /** + * {@inheritdoc} + */ + public function mapDataToViews($data, $views) + { + $empty = null === $data || array() === $data; + if (!$empty && !is_array($data) && !is_object($data)) { + throw new UnexpectedTypeException($data, 'object, array or empty'); + } + foreach ($views as $view) { + $propertyPath = $view->getPropertyPath(); + $config = $view->getConfig(); + if (!$empty && null !== $propertyPath && $config->isMapped()) { + $view->setData($this->propertyAccessor->getValue($data, $propertyPath)); + } else { + $view->setData($view->getConfig()->getData()); + } + } + } + + /** + * {@inheritdoc} + */ + public function mapViewsToData($views, &$data) + { + if (null === $data) { + return; + } + if (!is_array($data) && !is_object($data)) { + throw new UnexpectedTypeException($data, 'object, array or empty'); + } + foreach ($views as $view) { + $propertyPath = $view->getPropertyPath(); + $config = $view->getConfig(); + + if (null !== $propertyPath && $config->getMapped()) { + // If the field is of type DateTime and the data is the same skip the update to + // keep the original object hash + if ($view->getData() instanceof \DateTime && $view->getData() == $this->propertyAccessor->getValue($data, $propertyPath)) { + continue; + } + // If the data is identical to the value in $data, we are + // dealing with a reference + if (!is_object($data) || !$config->getByReference() || $view->getData() !== $this->propertyAccessor->getValue($data, $propertyPath)) { + $this->propertyAccessor->setValue($data, $propertyPath, $view->getData()); + } + } + } + } + } diff --git a/src/Blast/Component/View/DataTransformer/DataTransformerInterface.php b/src/Blast/Component/View/DataTransformer/DataTransformerInterface.php new file mode 100644 index 00000000..b911104b --- /dev/null +++ b/src/Blast/Component/View/DataTransformer/DataTransformerInterface.php @@ -0,0 +1,20 @@ + + */ +class LogicException extends \LogicException +{ +} diff --git a/src/Blast/Component/View/Exception/TransformationFailedException.php b/src/Blast/Component/View/Exception/TransformationFailedException.php new file mode 100644 index 00000000..6199350c --- /dev/null +++ b/src/Blast/Component/View/Exception/TransformationFailedException.php @@ -0,0 +1,20 @@ + + */ +class TransformationFailedException extends \RuntimeException +{ +} diff --git a/src/Blast/Component/View/Exception/UnexpectedTypeException.php b/src/Blast/Component/View/Exception/UnexpectedTypeException.php new file mode 100644 index 00000000..38c06bc7 --- /dev/null +++ b/src/Blast/Component/View/Exception/UnexpectedTypeException.php @@ -0,0 +1,24 @@ + + */ +class UnexpectedTypeException extends \InvalidArgumentException +{ + public function __construct($value, string $expectedType) + { + parent::__construct(sprintf('Expected argument of type "%s", "%s" given', $expectedType, is_object($value) ? get_class($value) : gettype($value))); + } +} diff --git a/src/Blast/Component/View/Factory/ViewFactory.php b/src/Blast/Component/View/Factory/ViewFactory.php new file mode 100644 index 00000000..a67eb48c --- /dev/null +++ b/src/Blast/Component/View/Factory/ViewFactory.php @@ -0,0 +1,58 @@ + + */ +class ViewFactory implements ViewFactoryInterface +{ + private const DEFAULT_VIEW_TYPE = 'Blast\Component\View\Type\ViewType'; + private const DEFAULT_FIELD_TYPE = 'Blast\Component\View\Type\TextType'; + /** + * @var ViewTypeRegistryInterface + */ + private $registry; + + public function __construct(ViewTypeRegistryInterface $registry) + { + $this->registry = $registry; + } + + public function createBuilder($type = self::DEFAULT_VIEW_TYPE, $data = null, array $options = array()) + { + if (!is_string($type)) { + throw new UnexpectedTypeException($type, 'string'); + } + + return $this->createNamedBuilder($this->registry->getType($type)->getBlockPrefix(), $type, $data, $options); + } + + public function createNamedBuilder($name, $type = self::DEFAULT_VIEW_TYPE, $data = null, array $options = array()) + { + if (null !== $data && !array_key_exists('data', $options)) { + $options['data'] = $data; + } + if (!is_string($type)) { + throw new UnexpectedTypeException($type, 'string'); + } + $type = $this->registry->getType($type); + $builder = $type->createBuilder($this, $name, $options); + $type->buildView($builder, $builder->getOptions()); + + return $builder; + } +} diff --git a/src/Blast/Component/View/Factory/ViewFactoryInterface.php b/src/Blast/Component/View/Factory/ViewFactoryInterface.php new file mode 100644 index 00000000..f920a2c1 --- /dev/null +++ b/src/Blast/Component/View/Factory/ViewFactoryInterface.php @@ -0,0 +1,20 @@ + + */ +interface ViewFactoryInterface +{ +} diff --git a/src/Blast/Component/View/Registry/ViewTypeRegistry.php b/src/Blast/Component/View/Registry/ViewTypeRegistry.php new file mode 100644 index 00000000..7b483a97 --- /dev/null +++ b/src/Blast/Component/View/Registry/ViewTypeRegistry.php @@ -0,0 +1,77 @@ + + */ +class ViewTypeRegistry implements ViewTypeRegistryInterface +{ + /** + * @var ViewTypeInterface[] + */ + private $types = array(); + + public function __construct() + { + } + + public function getType($name): ViewTypeInterface + { + if (!isset($this->types[$name])) { + $type = null; + + // Support fully-qualified class names + if (!class_exists($name)) { + throw new InvalidArgumentException(sprintf('Could not load type "%s": class does not exist.', $name)); + } + if (!is_subclass_of($name, 'Blast\Component\View\ViewTypeInterface')) { + throw new InvalidArgumentException(sprintf('Could not load type "%s": class does not implement "Blast\Component\View\ViewTypeInterface".', $name)); + } + + $type = new $name(); + + $this->types[$name] = $this->resolveType($type); + } + + return $this->types[$name]; + } + + /** + * Wraps a type into a ResolvedFormTypeInterface implementation and connects + * it with its parent type. + * + * @param FormTypeInterface $type The type to resolve + * + * @return ResolvedFormTypeInterface The resolved type + */ + private function resolveType(ViewTypeInterface $type) + { + $parentType = $type->getParent(); + $fqcn = get_class($type); + + return $this->createResolvedType( + $type, $parentType ? $this->getType($parentType) : null + ); + } + + public function createResolvedType(ViewTypeInterface $type, ResolvedViewTypeInterface $parent = null) + { + return new ResolvedViewType($type, $parent); + } +} diff --git a/src/Blast/Component/View/Registry/ViewTypeRegistryInterface.php b/src/Blast/Component/View/Registry/ViewTypeRegistryInterface.php new file mode 100644 index 00000000..578b5594 --- /dev/null +++ b/src/Blast/Component/View/Registry/ViewTypeRegistryInterface.php @@ -0,0 +1,23 @@ + + */ +interface ViewTypeRegistryInterface +{ + public function getType($name): ViewTypeInterface; +} diff --git a/src/Blast/Component/View/RenderingView.php b/src/Blast/Component/View/RenderingView.php new file mode 100644 index 00000000..9fd38aa4 --- /dev/null +++ b/src/Blast/Component/View/RenderingView.php @@ -0,0 +1,22 @@ + + */ + class RenderingView extends FormView + { + } diff --git a/src/Blast/Component/View/ResolvedType/ResolvedViewType.php b/src/Blast/Component/View/ResolvedType/ResolvedViewType.php new file mode 100644 index 00000000..d03cc888 --- /dev/null +++ b/src/Blast/Component/View/ResolvedType/ResolvedViewType.php @@ -0,0 +1,143 @@ + + */ +class ResolvedViewType implements ResolvedViewTypeInterface +{ + /** + * @var ViewTypeInterface + */ + private $innerType; + + /** + * @var ResolvedViewTypeInterface|null + */ + private $parent; + + /** + * @var OptionsResolver + */ + private $optionsResolver; + + public function __construct(ViewTypeInterface $innerType, ResolvedViewTypeInterface $parent = null) + { + $this->innerType = $innerType; + $this->parent = $parent; + } + + /** + * {@inheritdoc} + */ + public function getBlockPrefix() + { + return $this->innerType->getBlockPrefix(); + } + + /** + * {@inheritdoc} + */ + public function getParent() + { + return $this->parent; + } + + /** + * {@inheritdoc} + */ + public function getInnerType() + { + return $this->innerType; + } + + public function createBuilder(ViewFactoryInterface $factory, $name, array $options = array()) + { + $options = $this->getOptionsResolver()->resolve($options); + $dataClass = isset($options['data_class']) ? $options['data_class'] : null; + $builder = new ViewBuilder($name, $dataClass, $factory, $options); + $builder->setType($this); + + return $builder; + } + + public function createRenderingView(ViewInterface $view, RenderingView $parent = null) + { + return new RenderingView($parent); + } + + public function buildView(ViewBuilderInterface $builder, array $options) + { + if (null !== $this->parent) { + $this->parent->buildView($builder, $options); + } + + $this->innerType->buildView($builder, $options); + } + + public function buildRenderingView(RenderingView $rview, ViewInterface $view, array $options) + { + if (null !== $this->parent) { + $this->parent->buildRenderingView($rview, $view, $options); + } + + $this->innerType->buildRenderingView($rview, $view, $options); + } + + public function finishRenderingView(RenderingView $rview, ViewInterface $view, array $options) + { + if (null !== $this->parent) { + $this->parent->finishRenderingView($rview, $view, $options); + } + + $this->innerType->finishRenderingView($rview, $view, $options); + } + + public function onPreSetData(ViewInterface $view, $data) + { + if (null !== $this->parent) { + $this->parent->onPreSetData($view, $data); + } + $this->innerType->onPreSetData($view, $data); + } + + public function onPostSetData(ViewInterface $view, $data) + { + if (null !== $this->parent) { + $this->parent->onPostSetData($view, $data); + } + $this->innerType->onPostSetData($view, $data); + } + + public function getOptionsResolver() + { + if (null === $this->optionsResolver) { + if (null !== $this->parent) { + $this->optionsResolver = clone $this->parent->getOptionsResolver(); + } else { + $this->optionsResolver = new OptionsResolver(); + } + + $this->innerType->configureOptions($this->optionsResolver); + } + + return $this->optionsResolver; + } +} diff --git a/src/Blast/Component/View/ResolvedType/ResolvedViewTypeInterface.php b/src/Blast/Component/View/ResolvedType/ResolvedViewTypeInterface.php new file mode 100644 index 00000000..ba537344 --- /dev/null +++ b/src/Blast/Component/View/ResolvedType/ResolvedViewTypeInterface.php @@ -0,0 +1,42 @@ + + */ +interface ResolvedViewTypeInterface extends ViewTypeInterface +{ + /** + * Returns the prefix of the template block name for this type. + * + * @return string The prefix of the template block name + */ + public function getBlockPrefix(); + + /** + * Returns the parent type. + * + * @return self|null The parent type or null + */ + public function getParent(); + + /** + * Returns the wrapped form type. + * + * @return ViewTypeInterface The wrapped form type + */ + public function getInnerType(); +} diff --git a/src/Blast/Component/View/Serializer/JsonSerializer.php b/src/Blast/Component/View/Serializer/JsonSerializer.php new file mode 100644 index 00000000..7cdfa93c --- /dev/null +++ b/src/Blast/Component/View/Serializer/JsonSerializer.php @@ -0,0 +1,32 @@ + + */ + class JsonSerializer + { + public function serialize(RenderingView $rview) + { + $encoders = [new JsonEncoder()]; + $normalizers = [new RenderingViewNormalizer()]; + $serializer = new Serializer($normalizers, $encoders); + + return $serializer->serialize($rview, 'json'); + } + } diff --git a/src/Blast/Component/View/Serializer/RenderingViewNormalizer.php b/src/Blast/Component/View/Serializer/RenderingViewNormalizer.php new file mode 100644 index 00000000..a8e167bd --- /dev/null +++ b/src/Blast/Component/View/Serializer/RenderingViewNormalizer.php @@ -0,0 +1,41 @@ + + */ + class RenderingViewNormalizer implements NormalizerInterface + { + public function normalize($object, $format = null, array $context = []) + { + $data = []; + + if (!$object->vars['compound']) { + return $object->vars['value']; + } + + foreach ($object->children as $child) { + $data[$child->vars['name']] = $this->normalize($child); + } + + return $data; + } + + public function supportsNormalization($data, $format = null) + { + return true; + } + } diff --git a/src/Blast/Component/View/Tests/Resources/autoload.php.dist b/src/Blast/Component/View/Tests/Resources/autoload.php.dist new file mode 100644 index 00000000..05f9d6de --- /dev/null +++ b/src/Blast/Component/View/Tests/Resources/autoload.php.dist @@ -0,0 +1,27 @@ + + */ +abstract class AbstractType implements ViewTypeInterface +{ + public function buildView(ViewBuilderInterface $builder, array $options) + { + $builder->setCondition($options['condition']); + } + + public function buildRenderingView(RenderingView $rview, ViewInterface $view, array $options) + { + } + + public function finishRenderingView(RenderingView $rview, ViewInterface $view, array $options) + { + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefault('condition', null); + } + + public function getBlockPrefix(): string + { + return StringUtil::fqcnToBlockPrefix(get_class($this)); + } + + public function onPreSetData(ViewInterface $view, $data) + { + } + + public function onPostSetData(ViewInterface $view, $data) + { + } + + public function getParent() + { + return 'Blast\Component\View\Type\ViewType'; + } +} diff --git a/src/Blast/Component/View/Type/BaseType.php b/src/Blast/Component/View/Type/BaseType.php new file mode 100644 index 00000000..c23ae1ce --- /dev/null +++ b/src/Blast/Component/View/Type/BaseType.php @@ -0,0 +1,109 @@ + + */ + class BaseType extends AbstractType + { + public function buildRenderingView(RenderingView $rview, ViewInterface $view, array $options) + { + parent::buildRenderingView($rview, $view, $options); + + $name = $view->getName(); + $blockName = $options['block_name'] ?: $view->getName(); + $translationDomain = $options['translation_domain']; + $labelFormat = $options['label_format']; + + if ($rview->parent) { + if ('' !== ($parentFullName = $rview->parent->vars['full_name'])) { + $id = sprintf('%s_%s', $rview->parent->vars['id'], $name); + $fullName = sprintf('%s[%s]', $parentFullName, $name); + $uniqueBlockPrefix = sprintf('%s_%s', $rview->parent->vars['unique_block_prefix'], $blockName); + } else { + $id = $name; + $fullName = $name; + $uniqueBlockPrefix = '_' . $blockName; + } + + if (null === $translationDomain) { + $translationDomain = $rview->parent->vars['translation_domain']; + } + + if (!$labelFormat) { + $labelFormat = $rview->parent->vars['label_format']; + } + } else { + $id = $name; + $fullName = $name; + $uniqueBlockPrefix = '_' . $blockName; + + // Strip leading underscores and digits. These are allowed in + // form names, but not in HTML4 ID attributes. + // http://www.w3.org/TR/html401/struct/global.html#adef-id + $id = ltrim($id, '_0123456789'); + } + + $blockPrefixes = array(); + for ($type = $view->getConfig()->getType(); null !== $type; $type = $type->getParent()) { + array_unshift($blockPrefixes, $type->getBlockPrefix()); + } + $blockPrefixes[] = $uniqueBlockPrefix; + + $rview->vars = array_replace($rview->vars, array( + 'view' => $rview, + 'id' => $id, + 'name' => $name, + 'full_name' => $fullName, + 'label' => $options['label'], + 'label_format' => $labelFormat, + 'attr' => $options['attr'], + 'block_prefixes' => $blockPrefixes, + 'unique_block_prefix' => $uniqueBlockPrefix, + 'translation_domain' => $translationDomain, + // Using the block name here speeds up performance in collection + // forms, where each entry has the same full block name. + // Including the type is important too, because if rows of a + // collection form have different types (dynamically), they should + // be rendered differently. + // https://github.com/symfony/symfony/issues/5038 + 'cache_key' => $uniqueBlockPrefix . '_' . $view->getConfig()->getType()->getBlockPrefix(), + )); + } + + public function configureOptions(OptionsResolver $resolver) + { + parent::configureOptions($resolver); + + $resolver->setDefaults(array( + 'block_name' => null, + 'label' => null, + 'label_format' => null, + 'attr' => array(), + 'translation_domain' => null, + )); + + $resolver->setAllowedTypes('attr', 'array'); + } + + public function getBlockPrefix(): string + { + return StringUtil::fqcnToBlockPrefix(get_class($this)); + } + } diff --git a/src/Blast/Component/View/Type/BooleanType.php b/src/Blast/Component/View/Type/BooleanType.php new file mode 100644 index 00000000..88d9bf51 --- /dev/null +++ b/src/Blast/Component/View/Type/BooleanType.php @@ -0,0 +1,56 @@ + + */ + class BooleanType extends BaseType implements DataTransformerInterface + { + public function buildView(ViewBuilderInterface $builder, array $options) + { + $builder->addDataTransformer($this); + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults(array( + 'compound' => false, + )); + } + + public function getBlockPrefix(): string + { + return 'view_boolean'; + } + + /** + * {@inheritdoc} + */ + public function transform($data) + { + return (bool) $data; + } + + /** + * {@inheritdoc} + */ + public function reverseTransform($data) + { + return null === $data ? false : (bool) $data; + } + } diff --git a/src/Blast/Component/View/Type/CollectionType.php b/src/Blast/Component/View/Type/CollectionType.php new file mode 100644 index 00000000..817362bb --- /dev/null +++ b/src/Blast/Component/View/Type/CollectionType.php @@ -0,0 +1,109 @@ + + */ + class CollectionType extends AbstractType + { + public function buildView(ViewBuilderInterface $builder, array $options) + { + parent::buildView($builder, $options); + + if ($options['prototype']) { + $prototypeOptions = array_replace(array( + 'label' => $options['prototype_name'] . 'label__', + ), $options['entry_options']); + + if (null !== $options['prototype_data']) { + $prototypeOptions['data'] = $options['prototype_data']; + } + + $prototype = $builder->create($options['prototype_name'], $options['entry_type'], $prototypeOptions); + $builder->setAttribute('prototype', $prototype->getView()); + } + } + + /** + * {@inheritdoc} + */ + public function buildRenderingView(RenderingView $rview, ViewInterface $view, array $options) + { + parent::buildRenderingView($rview, $view, $options); + + $rview->vars = array_replace($rview->vars, array( + 'allow_add' => $options['allow_add'], + 'allow_delete' => $options['allow_delete'], + )); + + if ($view->getConfig()->hasAttribute('prototype')) { + $prototype = $view->getConfig()->getAttribute('prototype'); + $rview->vars['prototype'] = $prototype->setParent($view)->createRenderingView($rview); + + $prototype->setParent($view); + + foreach ($view->getData() as $index => $data) { + $prototype->getConfig()->setName($index); + $prototype->setData($data); + $rview->children[] = $prototype->createRenderingView($rview); + } + } + } + + /** + * {@inheritdoc} + */ + public function finishRenderingView(RenderingView $rview, ViewInterface $view, array $options) + { + parent::finishRenderingView($rview, $view, $options); + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver) + { + parent::configureOptions($resolver); + + $entryOptionsNormalizer = function (Options $options, $value) { + $value['block_name'] = 'entry'; + + return $value; + }; + + $resolver->setDefaults(array( + 'allow_add' => false, + 'allow_delete' => false, + 'prototype' => true, + 'prototype_data' => null, + 'prototype_name' => '__name__', + 'entry_type' => __NAMESPACE__ . '\TextType', + 'entry_options' => array(), + 'delete_empty' => false, + )); + + $resolver->setNormalizer('entry_options', $entryOptionsNormalizer); + } + + public function getBlockPrefix(): string + { + return 'view_collection'; + } + } diff --git a/src/Blast/Component/View/Type/TextType.php b/src/Blast/Component/View/Type/TextType.php new file mode 100644 index 00000000..14dceb86 --- /dev/null +++ b/src/Blast/Component/View/Type/TextType.php @@ -0,0 +1,33 @@ + + */ + class TextType extends BaseType + { + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults(array( + 'compound' => false, + )); + } + + public function getBlockPrefix(): string + { + return 'view_text'; + } + } diff --git a/src/Blast/Component/View/Type/ViewType.php b/src/Blast/Component/View/Type/ViewType.php new file mode 100644 index 00000000..86ca1f08 --- /dev/null +++ b/src/Blast/Component/View/Type/ViewType.php @@ -0,0 +1,124 @@ + + */ + class ViewType extends BaseType + { + private $propertyAccessor; + + public function __construct(PropertyAccessorInterface $propertyAccessor = null) + { + $this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor(); + } + + public function buildView(ViewBuilderInterface $builder, array $options) + { + parent::buildView($builder, $options); + + $isDataOptionSet = array_key_exists('data', $options); + + $builder + ->setPropertyPath($options['property_path']) + ->setMapped($options['mapped']) + ->setInheritData($options['inherit_data']) + ->setCompound($options['compound']) + ->setData($isDataOptionSet ? $options['data'] : null) + ->setDataLocked($isDataOptionSet) + ->setDataMapper($options['compound'] ? new PropertyPathMapper($this->propertyAccessor) : null); + } + + public function buildRenderingView(RenderingView $rview, ViewInterface $view, array $options) + { + parent::buildRenderingView($rview, $view, $options); + + $name = $view->getName(); + + if ($rview->parent) { + if ('' === $name) { + throw new LogicException('Form node with empty name can be used only as root form node.'); + } + + // Complex fields are read-only if they themselves or their parents are. + if (!isset($rview->vars['attr']['readonly']) && isset($rview->parent->vars['attr']['readonly']) && false !== $rview->parent->vars['attr']['readonly']) { + $rview->vars['attr']['readonly'] = true; + } + } + + $rview->vars = array_replace($rview->vars, array( + 'value' => $view->getViewData(), + 'data' => $view->getData(), + 'label_attr' => $options['label_attr'], + 'compound' => $view->getConfig()->getCompound(), + )); + } + + /** + * {@inheritdoc} + */ + public function finishRenderingView(RenderingView $rview, ViewInterface $view, array $options) + { + parent::finishRenderingView($rview, $view, $options); + } + + /** + * {@inheritdoc} + */ + public function configureOptions(OptionsResolver $resolver) + { + parent::configureOptions($resolver); + + // Derive "data_class" option from passed "data" object + $dataClass = function (Options $options) { + return isset($options['data']) && is_object($options['data']) ? get_class($options['data']) : null; + }; + + // If data is given, the form is locked to that data + // (independent of its value) + $resolver->setDefined(array('data')); + + $resolver->setDefaults(array( + 'data_class' => $dataClass, + 'property_path' => null, + 'mapped' => true, + 'label_attr' => array(), + 'inherit_data' => false, + 'compound' => true, + 'attr' => array(), + )); + + $resolver->setAllowedTypes('label_attr', 'array'); + } + + public function getBlockPrefix(): string + { + return 'view'; + } + + public function getParent() + { + return null; + } + } diff --git a/src/Blast/Component/View/Type/ViewTypeInterface.php b/src/Blast/Component/View/Type/ViewTypeInterface.php new file mode 100644 index 00000000..9451cf31 --- /dev/null +++ b/src/Blast/Component/View/Type/ViewTypeInterface.php @@ -0,0 +1,20 @@ + + */ +interface ViewTypeInterface +{ +} diff --git a/src/Blast/Component/View/View.php b/src/Blast/Component/View/View.php new file mode 100644 index 00000000..400586a4 --- /dev/null +++ b/src/Blast/Component/View/View.php @@ -0,0 +1,416 @@ + + */ +class View implements ViewInterface, \IteratorAggregate, \ArrayAccess, \Countable +{ + /** + * The parent of this view. + * + * @var ViewInterface + */ + public $parent; + /** + * The view's configuration. + * + * @var ViewConfigInterface + */ + private $config; + + /** + * The children of this view. + * + * @var ViewInterface[] A map of ViewInterface instances + */ + public $children; + /** + * The view data. + * + * @var mixed + */ + private $viewData; + + /** + * @var bool + */ + private $defaultDataSet = false; + + /** + * @var bool + */ + private $lockSetData = false; + + public function __construct(ViewConfigInterface $config) + { + if ($config->getCompound() && !$config->getDataMapper()) { + throw new LogicException('Compound views need a data mapper'); + } + + if ($config->getInheritData()) { + $this->defaultDataSet = true; + } + + $this->config = $config; + $this->children = new OrderedHashMap(); + } + + public function __clone() + { + $this->children = clone $this->children; + + foreach ($this->children as $key => $child) { + $this->children[$key] = clone $child; + } + } + + public function createRenderingView(RenderingView $parent = null) + { + if ($this->config->hasCondition() && !$this->config->checkCondition($this)) { + return null; + } + $type = $this->config->getType(); + $options = $this->config->getOptions(); + $rview = $type->createRenderingView($this, $parent); + $type->buildRenderingView($rview, $this, $options); + + foreach ($this->children as $name => $child) { + $childrview = $child->createRenderingView($rview); + + if (null !== $childrview) { + $rview->children[$name] = $childrview; + } + } + + $type->finishRenderingView($rview, $this, $options); + + return $rview; + } + + public function getName() + { + return $this->config->getName(); + } + + public function getConfig() + { + return $this->config; + } + + public function getPropertyPath() + { + if (null !== $this->config->getPropertyPath()) { + return $this->config->getPropertyPath(); + } + + if (null === $this->getName() || '' === $this->getName()) { + return; + } + + $parent = $this->parent; + + while ($parent && $parent->getConfig()->getInheritData()) { + $parent = $parent->getParent(); + } + + /*if ($parent && null === $parent->getConfig()->getDataClass()) { + return new PropertyPath('[' . $this->getName() . ']'); + }*/ + + return new PropertyPath($this->getName()); + } + + public function getViewData() + { + if ($this->config->getInheritData()) { + if (!$this->parent) { + throw new \RuntimeException('The view is configured to inherit its parent\'s data, but does not have a parent.'); + } + + return $this->parent->getViewData(); + } + if (!$this->defaultDataSet) { + $this->setData($this->config->getData()); + } + + return $this->viewData; + } + + public function getData() + { + if ($this->config->getInheritData()) { + if (!$this->parent) { + throw new RuntimeException('The form is configured to inherit its parent\'s data, but does not have a parent.'); + } + + return $this->parent->getData(); + } + + if (!$this->defaultDataSet) { + if ($this->lockSetData) { + throw new \RuntimeException('A cycle was detected.'); + } + + $this->setData($this->config->getData()); + } + + return $this->modelData; + } + + public function setData($modelData) + { + // If the form inherits its parent's data, disallow data setting to + // prevent merge conflicts + if ($this->config->getInheritData()) { + throw new RuntimeException('You cannot change the data of a view inheriting its parent data.'); + } + // Don't allow modifications of the configured data if the data is locked + if ($this->config->getDataLocked() && $modelData !== $this->config->getData()) { + return $this; + } + /*if (is_object($modelData) && !$this->config->getByReference()) { + $modelData = clone $modelData; + }*/ + if ($this->lockSetData) { + throw new RuntimeException('A cycle was detected. Listeners to the PRE_SET_DATA event must not call setData(). You should call setData() on the FormEvent object instead.'); + } + + $this->lockSetData = true; + // Hook to change content of the data + $this->config->getType()->onPreSetData($this, $modelData); + + // Treat data as strings unless a value transformer exists + if (!$this->config->getDataTransformers() && is_scalar($modelData)) { + $modelData = (string) $modelData; + } + + $viewData = $this->modelToViewData($modelData); + // Validate if view data matches data class (unless empty) + if (null === $viewData || '' === $viewData) { + $dataClass = $this->config->getDataClass(); + + if (null !== $dataClass && !$viewData instanceof $dataClass) { + $actualType = is_object($viewData) + ? 'an instance of class ' . get_class($viewData) + : 'a(n) ' . gettype($viewData); + throw new \LogicException( + 'The view\'s data is expected to be an instance of class ' . + $dataClass . ', but is ' . $actualType . '. You can avoid this error ' . + 'by setting the "data_class" option to null or by adding a data ' . + 'transformer that transforms ' . $actualType . ' to an instance of ' . + $dataClass . '.' + ); + } + } + $this->modelData = $modelData; + $this->viewData = $viewData; + $this->defaultDataSet = true; + $this->lockSetData = false; + // It is not necessary to invoke this method if the form doesn't have children, + // even if the form is compound. + if (count($this->children) > 0) { + // Update child forms from the data + $iterator = new InheritDataAwareIterator($this->children); + $iterator = new \RecursiveIteratorIterator($iterator); + $this->config->getDataMapper()->mapDataToViews($viewData, $iterator); + } + + $this->config->getType()->onPostSetData($this, $modelData); + + return $this; + } + + /** + * Normalizes the value if a normalization transformer is set. + * + * @param mixed $value The value to transform + * + * @return mixed + * + * @throws TransformationFailedException If the value cannot be transformed to "normalized" format + */ + private function modelToViewData($value) + { + try { + foreach ($this->config->getDataTransformers() as $transformer) { + $value = $transformer->transform($value); + } + } catch (TransformationFailedException $exception) { + throw new TransformationFailedException( + 'Unable to transform value for property path "' . $this->getPropertyPath() . '": ' . $exception->getMessage(), + $exception->getCode(), + $exception + ); + } + + return $value; + } + + public function getParent() + { + return $this->parent; + } + + public function setParent(ViewInterface $parent = null) + { + if (null !== $parent && '' === $this->config->getName()) { + throw new LogicException('A view with an empty name cannot have a parent view.'); + } + $this->parent = $parent; + + return $this; + } + + public function hasParent() + { + return null !== $this->parent; + } + + public function getRoot() + { + return $this->isRoot() ? $this : $this->parent->getRoot(); + } + + public function isRoot() + { + return null === $this->parent; + } + + public function add(ViewInterface $child, $type = null, array $options = array()) + { + if (!$this->config->getCompound()) { + throw new \LogicException('You cannot add children to a simple view. Maybe you should set the option "compound" to true?'); + } + + $viewData = null; + + if (!$this->lockSetData && $this->defaultDataSet && !$this->config->getInheritData()) { + $viewData = $this->getViewData(); + } + + $this->children[$child->getName()] = $child; + $child->setParent($this); + + if (!$this->lockSetData && $this->defaultDataSet && !$this->config->getInheritData()) { + $iterator = new InheritDataAwareIterator(new \ArrayIterator(array($child->getName() => $child))); + $iterator = new \RecursiveIteratorIterator($iterator); + $this->config->getDataMapper()->mapDataToForms($viewData, $iterator); + } + + return $this; + } + + public function all() + { + return iterator_to_array($this->children); + } + + public function has($name) + { + return isset($this->children[$name]); + } + + /** + * {@inheritdoc} + */ + public function get($name) + { + if (isset($this->children[$name])) { + return $this->children[$name]; + } + throw new OutOfBoundsException(sprintf('Child "%s" does not exist.', $name)); + } + + /** + * Returns whether a child with the given name exists (implements the \ArrayAccess interface). + * + * @param string $name The name of the child + * + * @return bool + */ + public function offsetExists($name) + { + return $this->has($name); + } + + /** + * Returns the child with the given name (implements the \ArrayAccess interface). + * + * @param string $name The name of the child + * + * @return FormInterface The child form + * + * @throws \OutOfBoundsException if the named child does not exist + */ + public function offsetGet($name) + { + return $this->get($name); + } + + /** + * Adds a child to the form (implements the \ArrayAccess interface). + * + * @param string $name Ignored. The name of the child is used + * @param FormInterface $child The child to be added + * + * @throws AlreadySubmittedException if the form has already been submitted + * @throws LogicException when trying to add a child to a non-compound form + * + * @see self::add() + */ + public function offsetSet($name, $child) + { + $this->add($child); + } + + /** + * Removes the child with the given name from the form (implements the \ArrayAccess interface). + * + * @param string $name The name of the child to remove + * + * @throws AlreadySubmittedException if the form has already been submitted + */ + public function offsetUnset($name) + { + $this->remove($name); + } + + /** + * Returns the iterator for this group. + * + * @return \Traversable|FormInterface[] + */ + public function getIterator() + { + return $this->children; + } + + /** + * Returns the number of form children (implements the \Countable interface). + * + * @return int The number of embedded form children + */ + public function count() + { + return count($this->children); + } +} diff --git a/src/Blast/Component/View/ViewInterface.php b/src/Blast/Component/View/ViewInterface.php new file mode 100644 index 00000000..e2b67928 --- /dev/null +++ b/src/Blast/Component/View/ViewInterface.php @@ -0,0 +1,20 @@ + + */ +interface ViewInterface +{ +} diff --git a/src/Blast/Component/View/composer.json b/src/Blast/Component/View/composer.json new file mode 100644 index 00000000..ff0ac309 --- /dev/null +++ b/src/Blast/Component/View/composer.json @@ -0,0 +1,37 @@ +{ + "name": "blast-project/view", + "type": "library", + "description": "View Model", + "require": { + "php": "^7.1", + "symfony/form": "^3.4" + }, + "require-dev": { + "phpunit/phpunit": "^6.4" + }, + "license": ["LGPL-3.0-only"], + "keywords": [ + "view", + ], + "homepage": "https://github.com/blast-prject/Resource", + "authors": [ + { + "name": "Glenn Cavarlé", + "email": "glenn.cavarle@libre-informatique.fr" + }, + { + "name": "Libre Informatique", + "homepage": "http://www.libre-informatique.fr/" + } + ], + + "autoload": { + "psr-4": { + "Blast\\Component\\View\\": "." + } + }, + "version": "dev-wip-platform", + "config": { + "bin-dir": "bin" + } +} diff --git a/src/Blast/Component/View/phpunit.xml.dist b/src/Blast/Component/View/phpunit.xml.dist new file mode 100644 index 00000000..231b362a --- /dev/null +++ b/src/Blast/Component/View/phpunit.xml.dist @@ -0,0 +1,50 @@ + + + + + + + + ./Tests/Unit + + + + + + + ./ + + ./Tests/ + ./Resources/ + ./vendor/ + ./coverage/ + + + + + + + + + + + + + + + +