├── .gitignore ├── Attachment ├── Attachment.php ├── AttachmentInterface.php ├── PathHelper.php ├── Sanitiser.php ├── Streamer.php └── Uploader.php ├── CHANGELOG.md ├── DependencyInjection ├── Configuration.php └── InfiniteFormExtension.php ├── Form ├── DataTransformer │ ├── AnythingToBooleanTransformer.php │ ├── CheckboxGridTransformer.php │ ├── EntitySearchTransformer.php │ └── EntitySearchTransformerFactory.php ├── EventListener │ ├── CheckboxRowCreationListener.php │ └── ResizePolyFormListener.php ├── Type │ ├── AttachmentType.php │ ├── CheckboxGridType.php │ ├── CheckboxRowType.php │ ├── EntityCheckboxGridType.php │ ├── EntitySearchType.php │ └── PolyCollectionType.php └── Util │ ├── ChoiceListViewAdapter.php │ └── LegacyChoiceListUtil.php ├── InfiniteFormBundle.php ├── README.md ├── Resources ├── config │ ├── attachment.xml │ ├── checkbox_grid.xml │ ├── entity_search.xml │ ├── polycollection.xml │ └── twig.xml ├── doc │ ├── attachment.md │ ├── checkboxgrid.md │ ├── checkboxgrid.png │ ├── collection-helper.md │ ├── entitysearch.md │ ├── installation.md │ ├── polycollection.md │ └── twig-helper.md ├── meta │ └── LICENSE ├── public │ └── js │ │ ├── collections.js │ │ └── entity-search.js └── views │ ├── collection_table_theme.html.twig │ ├── collection_theme.html.twig │ └── form_theme.html.twig ├── Tests ├── Attachment │ ├── AttachmentFieldTest.php │ ├── AttachmentObjectTest.php │ ├── Attachments │ │ ├── FullHashAttachment.php │ │ ├── InvalidFormatAttachment.php │ │ └── StandardAttachment.php │ ├── SanitiserTest.php │ ├── StreamerTest.php │ └── UploaderTest.php ├── BundleTest.php ├── CheckboxGrid │ ├── CheckboxGridTest.php │ ├── Entity │ │ ├── Area.php │ │ ├── Product.php │ │ ├── Salesman.php │ │ └── SalesmanProductArea.php │ ├── EntityCheckboxGridTest.php │ ├── Model │ │ └── ColorFinish.php │ ├── TransformerTest.php │ └── Type │ │ └── SalesmanType.php ├── DependencyInjection │ ├── ConfigurationTest.php │ └── InfiniteFormExtensionTest.php ├── EntitySearch │ ├── Entity │ │ └── Fruit.php │ ├── EntitySearchTest.php │ └── EntitySearchTransformerTest.php ├── FormExtension │ └── FormExtensionTest.php ├── PolyCollection │ ├── FormExtension.php │ ├── Model │ │ ├── AbstractModel.php │ │ ├── First.php │ │ ├── Fourth.php │ │ ├── Second.php │ │ └── Third.php │ ├── PolyCollectionTypeTest.php │ └── Type │ │ ├── AbstractType.php │ │ ├── AbstractTypeIdType.php │ │ ├── FirstSpecificOptionsType.php │ │ ├── FirstType.php │ │ ├── FirstTypeIdType.php │ │ ├── FourthType.php │ │ ├── SecondSpecificOptionsType.php │ │ └── SecondType.php └── autoload.php.dist ├── Twig └── FormExtension.php ├── composer.json └── phpunit.xml.dist /.gitignore: -------------------------------------------------------------------------------- 1 | /composer.phar 2 | /composer.lock 3 | /phpunit.xml 4 | /vendor 5 | /.phpunit.result.cache 6 | /.idea 7 | -------------------------------------------------------------------------------- /Attachment/Attachment.php: -------------------------------------------------------------------------------- 1 | fileHash = $fileHash; 59 | } 60 | 61 | /** 62 | * @return string 63 | */ 64 | public function getFileHash() 65 | { 66 | return $this->fileHash; 67 | } 68 | 69 | /** 70 | * @param int $fileSize 71 | */ 72 | public function setFileSize($fileSize) 73 | { 74 | $this->fileSize = $fileSize; 75 | } 76 | 77 | /** 78 | * @return int 79 | */ 80 | public function getFileSize() 81 | { 82 | return $this->fileSize; 83 | } 84 | 85 | /** 86 | * @param string $filename 87 | */ 88 | public function setFilename($filename) 89 | { 90 | $this->filename = $filename; 91 | } 92 | 93 | /** 94 | * @return string 95 | */ 96 | public function getFilename() 97 | { 98 | return $this->filename; 99 | } 100 | 101 | /** 102 | * @param string $mimeType 103 | */ 104 | public function setMimeType($mimeType) 105 | { 106 | $this->mimeType = $mimeType; 107 | } 108 | 109 | /** 110 | * @return string 111 | */ 112 | public function getMimeType() 113 | { 114 | return $this->mimeType; 115 | } 116 | 117 | /** 118 | * @param string $physicalName 119 | */ 120 | public function setPhysicalName($physicalName) 121 | { 122 | $this->physicalName = $physicalName; 123 | } 124 | 125 | /** 126 | * @return string 127 | */ 128 | public function getPhysicalName() 129 | { 130 | return $this->physicalName; 131 | } 132 | 133 | public function isImage() 134 | { 135 | return substr($this->mimeType, 0, 6) === 'image/'; 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /Attachment/AttachmentInterface.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | interface AttachmentInterface 12 | { 13 | /** 14 | * The filename to be displayed to the user. 15 | * 16 | * @return string 17 | */ 18 | public function getFilename(); 19 | 20 | public function setFilename($filename); 21 | 22 | /** 23 | * SHA1 hash of the file's contents (for use as an Etag). 24 | * 25 | * @return string 26 | */ 27 | public function getFileHash(); 28 | 29 | public function setFileHash($fileHash); 30 | 31 | /** 32 | * File size in bytes. 33 | * 34 | * @return int 35 | */ 36 | public function getFileSize(); 37 | 38 | public function setFileSize($fileSize); 39 | 40 | /** 41 | * The MIME type of the file. 42 | * 43 | * @return string 44 | */ 45 | public function getMimeType(); 46 | 47 | public function setMimeType($mimeType); 48 | 49 | /** 50 | * The name of the saved file, relative to the upload path. 51 | * 52 | * @return string 53 | */ 54 | public function getPhysicalName(); 55 | 56 | public function setPhysicalName($physicalName); 57 | } 58 | -------------------------------------------------------------------------------- /Attachment/PathHelper.php: -------------------------------------------------------------------------------- 1 | sanitiser = $sanitiser; 24 | $this->config = $config; 25 | } 26 | 27 | /** 28 | * Returns the full path to the attachment. 29 | * 30 | * @param AttachmentInterface $attachment 31 | * 32 | * @return string 33 | */ 34 | public function getFullPath(AttachmentInterface $attachment) 35 | { 36 | return sprintf('%s/%s', $this->getSaveDir($attachment), $attachment->getPhysicalName()); 37 | } 38 | 39 | protected function getConfig(AttachmentInterface $attachment) 40 | { 41 | if (isset($this->config[get_class($attachment)])) { 42 | return $this->config[get_class($attachment)]; 43 | } else { 44 | foreach (class_parents($attachment) as $class) { 45 | if (isset($this->config[$class])) { 46 | return $this->config[$class]; 47 | } 48 | } 49 | } 50 | 51 | throw new \Exception('Class is not configured as an attachment: '.get_class($attachment)); 52 | } 53 | 54 | /** 55 | * Returns the root saving location for a specific attachment type. 56 | * 57 | * @param AttachmentInterface $attachment 58 | * 59 | * @return string 60 | */ 61 | public function getSaveDir(AttachmentInterface $attachment) 62 | { 63 | $cfg = $this->getConfig($attachment); 64 | 65 | return $cfg['dir']; 66 | } 67 | 68 | /** 69 | * @param AttachmentInterface $attachment 70 | * 71 | * @return string 72 | */ 73 | public function getFormat(AttachmentInterface $attachment) 74 | { 75 | $cfg = $this->getConfig($attachment); 76 | 77 | return $cfg['format']; 78 | } 79 | 80 | /** 81 | * Gets the full path for a file. 82 | * 83 | * @param UploadedFile $file 84 | * @param AttachmentInterface $attachment 85 | * @param string $hash 86 | * 87 | * @return string 88 | */ 89 | public function getName(UploadedFile $file, AttachmentInterface $attachment, $hash) 90 | { 91 | $format = $this->getFormat($attachment); 92 | $name = $this->buildName($file, $hash, $format, $attachment); 93 | 94 | return $name; 95 | } 96 | 97 | /** 98 | * Builds a filename to be used. 99 | * 100 | * @param UploadedFile $file 101 | * @param string $hash 102 | * @param string $format 103 | * @param AttachmentInterface $attachment 104 | * 105 | * @return string 106 | * 107 | * @throws \RuntimeException 108 | */ 109 | protected function buildName(UploadedFile $file, $hash, $format, AttachmentInterface $attachment) 110 | { 111 | // Whether to rename files in case two files have the same name. 112 | // (Set to false if the format string contains the full hash. In that case it's OK to have the same file.) 113 | $renameDupes = true; 114 | $sanitiser = $this->sanitiser; 115 | 116 | $name = preg_replace_callback( 117 | '/\{(\w+)(\((\d+)\.\.(\d+)\))?\}/', 118 | function ($match) use (&$renameDupes, $file, $format, $hash, $sanitiser) { 119 | /** @var UploadedFile $file */ 120 | $partName = $match[1]; 121 | $substrStart = count($match) >= 3 ? $match[3] : null; 122 | $substrEnd = count($match) >= 4 ? $match[4] : null; 123 | 124 | switch ($partName) { 125 | case 'hash': 126 | $value = $hash; 127 | 128 | if ($substrStart === null) { 129 | $renameDupes = false; 130 | } 131 | 132 | break; 133 | case 'name': 134 | $value = $sanitiser->sanitiseFilename($file->getClientOriginalName()); 135 | break; 136 | case 'ext': 137 | $value = pathinfo($file->getClientOriginalName(), PATHINFO_EXTENSION); 138 | break; 139 | default: 140 | throw new \RuntimeException(sprintf('Unknown name part: %s', $partName)); 141 | } 142 | 143 | if ($substrStart !== null) { 144 | $value = substr($value, $substrStart, $substrEnd); 145 | } 146 | 147 | return $value; 148 | }, 149 | $format 150 | ); 151 | 152 | if ($renameDupes) { 153 | $name = $this->ensureUnique($attachment, $name); 154 | } 155 | 156 | return $name; 157 | } 158 | 159 | /** 160 | * @param AttachmentInterface $attachment 161 | * @param $name 162 | * 163 | * @return string 164 | */ 165 | protected function ensureUnique(AttachmentInterface $attachment, $name) 166 | { 167 | $saveDir = $this->getSaveDir($attachment); 168 | 169 | if (file_exists(sprintf('%s/%s', $saveDir, $name))) { 170 | $pathInfo = pathinfo($name); 171 | $i = 0; 172 | $dotExt = $pathInfo['extension'] !== '' ? 173 | '.'.$pathInfo['extension'] : 174 | ''; 175 | 176 | do { 177 | ++$i; 178 | $name = sprintf( 179 | '%s/%s_%d%s', 180 | $pathInfo['dirname'], 181 | $pathInfo['filename'], 182 | $i, 183 | $dotExt 184 | ); 185 | } while (file_exists(sprintf('%s/%s', $saveDir, $name))); 186 | } 187 | 188 | return $name; 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /Attachment/Sanitiser.php: -------------------------------------------------------------------------------- 1 | : " / \ | ? * 22 | $filename = preg_replace('/[\x00-\x1F\<\>\:\"\/\\\\|\?\*]/', '-', $filename); 23 | 24 | if ($filename == '' || $filename[0] == '.') { 25 | $filename = '_'.$filename; 26 | } 27 | 28 | return $filename; 29 | } 30 | 31 | /** 32 | * Sanitises a mime type. 33 | * 34 | * @param string $mime 35 | * 36 | * @return string 37 | */ 38 | public function sanitiseMimeType($mime) 39 | { 40 | // Mime types are foo/bar, where foo and bar can contain anything except 00 to 20 and ()<>@,::\"/[]?= 41 | if (!preg_match('/^[!#$%&\'*+\-.0-9A-Z\^_`a-z\{|\}~]+\/[!#$%&\'*+\-.0-9A-Z\^_`a-z\{|\}~]+$/', $mime ?? '')) { 42 | return 'application/octet-stream'; 43 | } 44 | 45 | return $mime; 46 | } 47 | 48 | function applyMaxLength($filename, $maxLength) 49 | { 50 | if (!preg_match('~^(.*?)([^/]+?)((\.[^/.]*?)?)$~', $filename, $matches)) { 51 | return substr($filename, 0, $maxLength); 52 | } 53 | list(, $path, $basename, $ext) = $matches; 54 | 55 | if (strlen($path) + strlen($ext) >= $maxLength) { 56 | return substr($filename, 0, $maxLength); 57 | } 58 | 59 | return $path . substr($basename, 0, $maxLength - strlen($path) - strlen($ext)) . $ext; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Attachment/Streamer.php: -------------------------------------------------------------------------------- 1 | pathHelper = $pathHelper; 39 | $this->sanitiser = $sanitiser; 40 | } 41 | 42 | public function stream(AttachmentInterface $attachment, ?Request $request = null, $disposition = null) 43 | { 44 | $fullPhysicalPath = $this->pathHelper->getFullPath($attachment); 45 | $mimeType = $this->sanitiser->sanitiseMimeType($attachment->getMimeType()); 46 | 47 | if (!file_exists($fullPhysicalPath)) { 48 | return new NotFoundHttpException(sprintf( 49 | 'Attachment %s not found.', 50 | $attachment->getFilename() 51 | )); 52 | } 53 | 54 | if (null !== $request and null === $disposition) { 55 | // If this is a safe MIME type, display inline ... 56 | $disposition = in_array($mimeType, static::$inlineMimes) ? 'inline' : 'attachment'; 57 | 58 | // Unless it's Internet Explorer prior to version 8, which can try 59 | // to guess the MIME type (dangerous!). A user-agent check is the 60 | // only real option here. 61 | $userAgent = $request->headers->get('User-Agent', ''); 62 | if (preg_match('/MSIE [1-7]\./', $userAgent) && !preg_match('/\) Opera/', $userAgent)) { 63 | $disposition = 'attachment'; 64 | } 65 | } else { 66 | $disposition = 'attachment'; 67 | } 68 | 69 | $headers = array( 70 | 'Cache-Control' => 'private, max-age=31536000', 71 | 'Content-Disposition' => sprintf( 72 | '%s; filename="%s"', 73 | $disposition, 74 | $this->sanitiser->sanitiseFilename($attachment->getFilename()) 75 | ), 76 | 'Content-Length' => $attachment->getFileSize(), 77 | 'Content-Type' => $mimeType, 78 | ); 79 | 80 | return new StreamedResponse(function () use ($fullPhysicalPath) { 81 | fpassthru(fopen($fullPhysicalPath, 'rb')); 82 | }, 200, $headers); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Attachment/Uploader.php: -------------------------------------------------------------------------------- 1 | pathHelper = $pathHelper; 15 | $this->sanitiser = $sanitiser; 16 | } 17 | 18 | /** 19 | * Accepts an uploaded file and fills out the details into an Attachment object. 20 | * 21 | * @param UploadedFile $file 22 | * @param AttachmentInterface $attachment 23 | * 24 | * @return \Symfony\Component\HttpFoundation\File\File 25 | */ 26 | public function acceptUpload(UploadedFile $file, AttachmentInterface $attachment) 27 | { 28 | $hash = sha1_file($file->getPathname()); 29 | $name = $this->sanitiser->applyMaxLength($this->pathHelper->getName($file, $attachment, $hash), 100); 30 | $filename = $this->sanitiser->applyMaxLength($this->sanitiser->sanitiseFilename($file->getClientOriginalName()), 100); 31 | $mimeType = $this->sanitiser->sanitiseMimeType($file->getMimeType()); 32 | 33 | $attachment->setFilename($filename); 34 | $attachment->setFileHash($hash); 35 | $attachment->setFileSize(filesize($file->getPathname())); 36 | $attachment->setMimeType($mimeType); 37 | $attachment->setPhysicalName($name); 38 | 39 | $fullPath = sprintf('%s/%s', $this->pathHelper->getSaveDir($attachment), $name); 40 | 41 | return $file->move(dirname($fullPath), basename($fullPath)); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 3.0.0 (04/12/2019) 2 | 3 | * Add support for Symfony 5 4 | * Drop support for Symfony 3 5 | 6 | 2.0.1 (06/02/2019) 7 | 8 | * Fix EntitySearchType and AttachmentType 9 | * Add a new collection theme and an auto-initialize option to the collection helper JS 10 | 11 | 2.0.0 (01/12/2018) 12 | 13 | * Add support for Symfony 4 14 | * Drop support for Symfony 2 15 | * AttachmentType and EntitySearchType currently broken 16 | 17 | 1.0.6 (05/05/2016) 18 | 19 | * Move FormTypeInterface deprecation from ResizePolyFormListener to PolyCollectionType 20 | * Fix issue with LegacyFormUtil not handling FormType instances in Symfony < 2.8 21 | 22 | 1.0.5 (28/04/2016) 23 | 24 | * Deprecated passing FormTypeInterfaces to Polycollection, to be removed in 2.0 25 | 26 | 1.0.4 (26/04/2016) 27 | 28 | * Fix misc deprecation warnings with Symfony 2.8+ 29 | * Added option to keep script tags in prototypes when rendering new collection rows 30 | 31 | 1.0.3 (26/11/2015) 32 | 33 | * Added indexing by ID for the Polycollection 34 | * Collection helper returns added row when calling addToCollection 35 | 36 | 1.0.2 (29/08/2015) 37 | 38 | * Actually fix deprecation warnings (except CheckboxGrid) 39 | 40 | 1.0.1 (19/06/2015) 41 | 42 | * Fix Symfony 2.7 deprecation warnings in the FormTypes #28 43 | 44 | 1.0.0 (16/06/2015) 45 | 46 | * Initial Stable release 47 | -------------------------------------------------------------------------------- /DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace Infinite\FormBundle\DependencyInjection; 11 | 12 | use Symfony\Component\Config\Definition\Builder\TreeBuilder; 13 | use Symfony\Component\Config\Definition\ConfigurationInterface; 14 | 15 | /** 16 | * This class contains the configuration information for the bundle. 17 | * 18 | * This information is solely responsible for how the different configuration 19 | * sections are normalized, and merged. 20 | */ 21 | class Configuration implements ConfigurationInterface 22 | { 23 | /** 24 | * Generates the configuration tree builder. 25 | * 26 | * @return TreeBuilder The tree builder 27 | */ 28 | public function getConfigTreeBuilder(): TreeBuilder 29 | { 30 | $treeBuilder = new TreeBuilder('infinite_form'); 31 | 32 | if (method_exists($treeBuilder, 'getRootNode')) { 33 | $rootNode = $treeBuilder->getRootNode(); 34 | } else { 35 | // BC 36 | $rootNode = $treeBuilder->root('infinite_form'); 37 | } 38 | 39 | $rootNode 40 | ->children() 41 | ->booleanNode('attachment') 42 | ->defaultTrue() 43 | ->end() 44 | ->arrayNode('attachments') 45 | ->useAttributeAsKey('class') 46 | ->prototype('array') 47 | ->children() 48 | ->scalarNode('dir')->end() 49 | ->scalarNode('format')->end() 50 | ->end() 51 | ->end() 52 | ->end() 53 | ->booleanNode('checkbox_grid') 54 | ->defaultTrue() 55 | ->end() 56 | ->booleanNode('entity_search') 57 | ->defaultTrue() 58 | ->end() 59 | ->booleanNode('polycollection') 60 | ->defaultTrue() 61 | ->end() 62 | ->booleanNode('twig') 63 | ->defaultTrue() 64 | ->end() 65 | ->end(); 66 | 67 | return $treeBuilder; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /DependencyInjection/InfiniteFormExtension.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace Infinite\FormBundle\DependencyInjection; 11 | 12 | use Symfony\Component\Config\FileLocator; 13 | use Symfony\Component\DependencyInjection\ContainerBuilder; 14 | use Symfony\Component\DependencyInjection\Loader\XmlFileLoader; 15 | use Symfony\Component\DependencyInjection\Extension\Extension; 16 | 17 | /** 18 | * Configures the DI container for InfiniteFormBundle. 19 | * 20 | * @author Tim Nagel 21 | */ 22 | class InfiniteFormExtension extends Extension 23 | { 24 | public function load(array $configs, ContainerBuilder $container): void 25 | { 26 | $configuration = $this->getConfiguration($configs, $container); 27 | $configs = $this->processConfiguration($configuration, $configs); 28 | 29 | $loader = new XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); 30 | 31 | if ($configs['attachment']) { 32 | $loader->load('attachment.xml'); 33 | 34 | $container->setParameter('infinite_form.attachment.save_config', $configs['attachments']); 35 | } 36 | 37 | if ($configs['checkbox_grid']) { 38 | $loader->load('checkbox_grid.xml'); 39 | } 40 | 41 | if ($configs['entity_search']) { 42 | $loader->load('entity_search.xml'); 43 | } 44 | 45 | if ($configs['polycollection']) { 46 | $loader->load('polycollection.xml'); 47 | } 48 | 49 | if ($configs['twig']) { 50 | $loader->load('twig.xml'); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Form/DataTransformer/AnythingToBooleanTransformer.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace Infinite\FormBundle\Form\DataTransformer; 11 | 12 | use Symfony\Component\Form\DataTransformerInterface; 13 | 14 | /** 15 | * Transforms a checkbox's posted value back into an arbitrary object. 16 | */ 17 | class AnythingToBooleanTransformer implements DataTransformerInterface 18 | { 19 | public function __construct( 20 | protected mixed $anythingValue 21 | ) 22 | { 23 | } 24 | 25 | public function transform(mixed $value): bool 26 | { 27 | return $value !== null; 28 | } 29 | 30 | public function reverseTransform(mixed $value): mixed 31 | { 32 | return empty($value) ? null : $this->anythingValue; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Form/DataTransformer/CheckboxGridTransformer.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace Infinite\FormBundle\Form\DataTransformer; 11 | 12 | use Symfony\Component\Form\ChoiceList\ChoiceListInterface; 13 | use Symfony\Component\Form\DataTransformerInterface; 14 | use Symfony\Component\Form\Exception\TransformationFailedException; 15 | use Symfony\Component\PropertyAccess\PropertyAccess; 16 | use Symfony\Component\PropertyAccess\PropertyPath; 17 | 18 | /** 19 | * Transforms a 1D array of data objects into a 2D array of booleans. 20 | */ 21 | class CheckboxGridTransformer implements DataTransformerInterface 22 | { 23 | /** @var string */ 24 | protected $class; 25 | 26 | /** @var ChoiceListInterface */ 27 | protected $xChoiceList; 28 | /** @var ChoiceListInterface */ 29 | protected $yChoiceList; 30 | 31 | /** @var PropertyPath */ 32 | protected $xPath; 33 | /** @var PropertyPath */ 34 | protected $yPath; 35 | 36 | public function __construct(array $options) 37 | { 38 | $this->xChoiceList = $options['x_choice_list']; 39 | $this->yChoiceList = $options['y_choice_list']; 40 | 41 | $this->xPath = new PropertyPath($options['x_path']); 42 | $this->yPath = new PropertyPath($options['y_path']); 43 | 44 | $this->class = $options['class']; 45 | } 46 | 47 | public function transform(mixed $value): array 48 | { 49 | if ($value === null) { 50 | return array(); 51 | } 52 | 53 | if (!is_array($value) && !($value instanceof \Traversable)) { 54 | throw new TransformationFailedException('Checkbox grid transformer needs an array as input'); 55 | } 56 | 57 | $vals = array(); 58 | 59 | $accessor = PropertyAccess::createPropertyAccessor(); 60 | 61 | foreach ($value as $object) { 62 | $xChoice = $accessor->getValue($object, $this->xPath); 63 | $yChoice = $accessor->getValue($object, $this->yPath); 64 | 65 | $xValueMatch = $this->xChoiceList->getValuesForChoices(array($xChoice)); 66 | $yValueMatch = $this->yChoiceList->getValuesForChoices(array($yChoice)); 67 | 68 | if (!$xValueMatch || !$yValueMatch) { 69 | continue; 70 | } 71 | 72 | // Instead of setting the checkbox's state to true, set it to $object. This will still check the box, 73 | // but the checkbox-creation code (which runs after this!) can remember this information. 74 | $vals[$yValueMatch[0]][$xValueMatch[0]] = $object; 75 | } 76 | 77 | return $vals; 78 | } 79 | 80 | public function reverseTransform(mixed $value): array 81 | { 82 | if (!is_array($value)) { 83 | throw new TransformationFailedException('Checkbox grid reverse-transformer needs an array as input'); 84 | } 85 | 86 | $result = array(); 87 | $accessor = PropertyAccess::createPropertyAccessor(); 88 | 89 | foreach ($value as $yValue => $row) { 90 | if (!is_array($row)) { 91 | throw new TransformationFailedException('Checkbox grid reverse-transformer needs a 2D array'); 92 | } 93 | 94 | $yChoiceMatch = $this->yChoiceList->getChoicesForValues(array($yValue)); 95 | 96 | if (!$yChoiceMatch) { 97 | continue; 98 | } 99 | 100 | foreach ($row as $xValue => $checked) { 101 | $xChoiceMatch = $this->xChoiceList->getChoicesForValues(array($xValue)); 102 | 103 | if ($xChoiceMatch && $checked) { 104 | if (is_bool($checked)) { 105 | if ($this->class === null) { 106 | $object = array(); 107 | } else { 108 | $object = new $this->class(); 109 | } 110 | } else { 111 | $object = $checked; 112 | } 113 | 114 | $accessor->setValue($object, $this->xPath, reset($xChoiceMatch)); 115 | $accessor->setValue($object, $this->yPath, reset($yChoiceMatch)); 116 | 117 | $result[] = $object; 118 | } 119 | } 120 | } 121 | 122 | return $result; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /Form/DataTransformer/EntitySearchTransformer.php: -------------------------------------------------------------------------------- 1 | om = $om; 23 | 24 | if (!isset($options['class'])) { 25 | throw new \InvalidArgumentException('Class not specified in EntitySearchTransformer::setOptions'); 26 | } 27 | 28 | if (!class_exists($options['class'])) { 29 | throw new \InvalidArgumentException(sprintf( 30 | 'Class "%s" not found in EntitySearchTransformer::__construct', 31 | $options['class'] 32 | )); 33 | } 34 | 35 | $this->class = $options['class']; 36 | 37 | if (isset($options['allow_not_found'])) { 38 | $this->allowNotFound = $options['allow_not_found']; 39 | } 40 | 41 | if (isset($options['name'])) { 42 | $this->nameField = $options['name']; 43 | } else { 44 | $this->nameField = 'name'; 45 | } 46 | 47 | $idFieldArray = $om->getClassMetadata($this->class)->getIdentifierFieldNames(); 48 | $this->idField = reset($idFieldArray); 49 | 50 | $this->accessor = PropertyAccess::createPropertyAccessor(); 51 | } 52 | 53 | public function transform(mixed $value): ?array 54 | { 55 | if ($value === null) { 56 | return null; 57 | } 58 | 59 | if (!$value instanceof $this->class) { 60 | throw new UnexpectedTypeException($value, $this->class); 61 | } 62 | 63 | return array( 64 | 'id' => $this->accessor->getValue($value, $this->idField), 65 | 'name' => $this->accessor->getValue($value, $this->nameField), 66 | ); 67 | } 68 | 69 | public function reverseTransform(mixed $value): mixed 70 | { 71 | if (!isset($value['id'])) { 72 | $value['id'] = ''; 73 | } 74 | 75 | if (!isset($value['name'])) { 76 | $value['name'] = ''; 77 | } 78 | 79 | $repository = $this->om->getRepository($this->class); 80 | if ($value['id'] != '') { 81 | $object = $repository->find($value['id']); 82 | 83 | if (!$this->allowNotFound && !isset($object)) { 84 | throw new TransformationFailedException('Transformation failed - object not found'); 85 | } 86 | } elseif ($value['name'] != '') { 87 | $object = $repository->findOneBy(array($this->nameField => $value['name'])); 88 | 89 | if (!$this->allowNotFound && !isset($object)) { 90 | throw new TransformationFailedException('Transformation failed - object not found'); 91 | } 92 | } else { 93 | $object = null; 94 | } 95 | 96 | return $object; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Form/DataTransformer/EntitySearchTransformerFactory.php: -------------------------------------------------------------------------------- 1 | registry = $registry; 18 | } 19 | 20 | public function createFromOptions($options): EntitySearchTransformer 21 | { 22 | $manager = $this->registry->getManagerForClass($options['class']); 23 | 24 | return new EntitySearchTransformer($manager, $options); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Form/EventListener/CheckboxRowCreationListener.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace Infinite\FormBundle\Form\EventListener; 11 | 12 | use Infinite\FormBundle\Form\DataTransformer\AnythingToBooleanTransformer; 13 | use Symfony\Component\Form\Extension\Core\Type\CheckboxType; 14 | use Symfony\Component\Form\Extension\Core\Type\FormType; 15 | use Symfony\Component\Form\FormEvents; 16 | use Symfony\Component\Form\FormEvent; 17 | use Symfony\Component\Form\FormFactoryInterface; 18 | use Symfony\Component\EventDispatcher\EventSubscriberInterface; 19 | use Symfony\Component\Form\FormInterface; 20 | 21 | /** 22 | * When a checkbox grid is created, there may already be a few boxes checked. When the grid is bound, 23 | * we want to match up any still-checked checkboxes with their original objects so that we don't have 24 | * to delete and recreate them (which would wipe out any bookkeeping data on them). 25 | * 26 | * To accomplish this, the checkbox must be linked to its original data object. The form framework 27 | * only allows this to be done when the checkbox is created, thus the checkboxes must be created when 28 | * the data is first available. 29 | * 30 | * This listener creates the link between checkboxes and their original data objects with a transformer. 31 | * The CheckboxGridTransformer then uses that value when it's available. 32 | */ 33 | class CheckboxRowCreationListener implements EventSubscriberInterface 34 | { 35 | protected $factory; 36 | 37 | public function __construct(FormFactoryInterface $factory) 38 | { 39 | $this->factory = $factory; 40 | } 41 | 42 | public static function getSubscribedEvents(): array 43 | { 44 | return array( 45 | FormEvents::PRE_SET_DATA => 'preSetData', 46 | ); 47 | } 48 | 49 | public function preSetData(FormEvent $event) 50 | { 51 | $data = $event->getData(); 52 | $form = $event->getForm(); 53 | 54 | if ($data === null) { 55 | $data = array(); 56 | } 57 | 58 | $options = $form->getConfig()->getOptions(); 59 | 60 | // Now that we have data available, create the checkboxes for the form. For every box that should 61 | // be checked, attach a transformer that will convert between its data object and a boolean. 62 | foreach ($options['choice_list']->getChoices() as $value => $choice) { 63 | $this->addCheckbox($options, $choice, $form, $value, $data); 64 | } 65 | } 66 | 67 | /** 68 | * @param array $options 69 | * @param $choice 70 | * @param FormInterface $form 71 | * @param $value 72 | * @param $data 73 | */ 74 | protected function addCheckbox($options, $choice, FormInterface $form, $value, $data) 75 | { 76 | if (isset($options['cell_filter']) && !$options['cell_filter']($choice, $options['row'])) { 77 | // Blank cell - put a dummy form control here 78 | $formType = FormType::class; 79 | } else { 80 | $formType = CheckboxType::class; 81 | } 82 | 83 | $builder = $this->factory->createNamedBuilder( 84 | $value, 85 | $formType, 86 | isset($data[$value]), 87 | array( 88 | 'auto_initialize' => false, 89 | 'required' => false, 90 | ) 91 | ); 92 | 93 | if (isset($data[$value])) { 94 | $builder->addViewTransformer(new AnythingToBooleanTransformer($data[$value]), true); 95 | } 96 | 97 | $form->add($builder->getForm()); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Form/EventListener/ResizePolyFormListener.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace Infinite\FormBundle\Form\EventListener; 11 | 12 | use Doctrine\Common\Util\ClassUtils; 13 | use Symfony\Component\Form\Exception\UnexpectedTypeException; 14 | use Symfony\Component\Form\Extension\Core\EventListener\ResizeFormListener; 15 | use Symfony\Component\Form\FormEvent; 16 | use Symfony\Component\Form\FormInterface; 17 | use Symfony\Component\Form\FormTypeInterface; 18 | use Symfony\Component\PropertyAccess\PropertyAccess; 19 | 20 | /** 21 | * A Form Resize listener capable of coping with a polycollection. 22 | * 23 | * @author Tim Nagel 24 | */ 25 | class ResizePolyFormListener extends ResizeFormListener 26 | { 27 | /** 28 | * Stores an array of Types with the Type name as the key. 29 | * 30 | * @var array 31 | */ 32 | protected $typeMap = array(); 33 | 34 | /** 35 | * Stores an array of types with the Data Class as the key. 36 | * 37 | * @var array 38 | */ 39 | protected $classMap = array(); 40 | 41 | /** 42 | * Name of the hidden field identifying the type. 43 | * 44 | * @var string 45 | */ 46 | protected $typeFieldName; 47 | 48 | /** 49 | * Name of the index field on the given entity. 50 | * 51 | * @var null|string 52 | */ 53 | protected $indexProperty; 54 | 55 | /** 56 | * Property Accessor. 57 | * 58 | * @var \Symfony\Component\PropertyAccess\PropertyAccessor 59 | */ 60 | protected $propertyAccessor; 61 | 62 | /** 63 | * @var bool 64 | */ 65 | protected $useTypesOptions; 66 | 67 | private string $_type; 68 | private array $_options; 69 | private bool $_allowAdd; 70 | private bool $_allowDelete; 71 | 72 | /** 73 | * @param array $prototypes 74 | * @param array $options 75 | * @param bool $allowAdd 76 | * @param bool $allowDelete 77 | * @param string $typeFieldName 78 | * @param string $indexProperty 79 | */ 80 | public function __construct(array $prototypes, array $options = array(), $allowAdd = false, $allowDelete = false, $typeFieldName = '_type', $indexProperty = null, $useTypesOptions = false) 81 | { 82 | $this->typeFieldName = $typeFieldName; 83 | $this->indexProperty = $indexProperty; 84 | $this->useTypesOptions = $useTypesOptions; 85 | $this->propertyAccessor = PropertyAccess::createPropertyAccessor(); 86 | $defaultType = null; 87 | 88 | foreach ($prototypes as $key => $prototype) { 89 | /** @var FormInterface $prototype */ 90 | $modelClass = $prototype->getConfig()->getOption('model_class'); 91 | $type = $prototype->getConfig()->getType()->getInnerType(); 92 | 93 | if (null === $defaultType) { 94 | $defaultType = $type; 95 | } 96 | 97 | $this->typeMap[$key] = get_class($type); 98 | $this->classMap[$modelClass] = get_class($type); 99 | } 100 | 101 | $this->_type = get_class($defaultType); 102 | $this->_options = $options; 103 | $this->_allowAdd = $allowAdd; 104 | $this->_allowDelete = $allowDelete; 105 | 106 | parent::__construct($this->_type, $this->_options, $this->_allowAdd, $this->_allowDelete); 107 | } 108 | 109 | /** 110 | * Returns the form type for the supplied object. If a specific 111 | * form type is not found, it will return the default form type. 112 | * 113 | * @param object $object 114 | * 115 | * @return string 116 | */ 117 | protected function getTypeForObject($object) 118 | { 119 | $class = get_class($object); 120 | $class = ClassUtils::getRealClass($class); 121 | $type = $this->_type; 122 | 123 | if (array_key_exists($class, $this->classMap)) { 124 | $type = $this->classMap[$class]; 125 | } 126 | 127 | return $type; 128 | } 129 | 130 | /** 131 | * Checks the form data for a hidden _type field that indicates 132 | * the form type to use to process the data. 133 | * 134 | * @param array $data 135 | * 136 | * @return string|FormTypeInterface 137 | * 138 | * @throws \InvalidArgumentException when _type is not present or is invalid 139 | */ 140 | protected function getTypeForData(array $data) 141 | { 142 | if (!array_key_exists($this->typeFieldName, $data) || !array_key_exists($data[$this->typeFieldName], $this->typeMap)) { 143 | throw new \InvalidArgumentException('Unable to determine the Type for given data'); 144 | } 145 | 146 | return $this->typeMap[$data[$this->typeFieldName]]; 147 | } 148 | 149 | protected function getOptionsForType($type) 150 | { 151 | if ($this->useTypesOptions === true) { 152 | return isset($this->_options[$type]) ? $this->_options[$type] : []; 153 | } else { 154 | return $this->_options; 155 | } 156 | } 157 | 158 | public function preSetData(FormEvent $event): void 159 | { 160 | $form = $event->getForm(); 161 | $data = $event->getData(); 162 | 163 | if (null === $data) { 164 | $data = array(); 165 | } 166 | 167 | if (!is_array($data) && !($data instanceof \Traversable && $data instanceof \ArrayAccess)) { 168 | throw new UnexpectedTypeException($data, 'array or (\Traversable and \ArrayAccess)'); 169 | } 170 | 171 | // First remove all rows 172 | foreach ($form as $name => $child) { 173 | $form->remove($name); 174 | } 175 | 176 | // Then add all rows again in the correct order for the incoming data 177 | foreach ($data as $name => $value) { 178 | $type = $this->getTypeForObject($value); 179 | $form->add($name, $type, array_replace(array( 180 | 'property_path' => '['.$name.']', 181 | ), $this->getOptionsForType($type))); 182 | } 183 | } 184 | 185 | public function preBind(FormEvent $event) 186 | { 187 | $this->preSubmit($event); 188 | } 189 | 190 | public function preSubmit(FormEvent $event): void 191 | { 192 | $form = $event->getForm(); 193 | $data = $event->getData(); 194 | 195 | if (null === $data || '' === $data) { 196 | $data = array(); 197 | } 198 | 199 | if (!is_array($data) && !($data instanceof \Traversable && $data instanceof \ArrayAccess)) { 200 | throw new UnexpectedTypeException($data, 'array or (\Traversable and \ArrayAccess)'); 201 | } 202 | 203 | // Process entries by IndexProperty 204 | if (!is_null($this->indexProperty)) { 205 | // Reindex the submit data by given index 206 | $indexedData = array(); 207 | $unindexedData = array(); 208 | $finalData = array(); 209 | foreach ($data as $item) { 210 | if (isset($item[$this->indexProperty])) { 211 | $indexedData[$item[$this->indexProperty]] = $item; 212 | } else { 213 | $unindexedData[] = $item; 214 | } 215 | } 216 | 217 | // Add all additional rows to the end of the array 218 | $name = $form->count(); 219 | foreach ($unindexedData as $item) { 220 | if ($this->_allowAdd) { 221 | $type = $this->getTypeForData($item); 222 | $form->add($name, $type, array_replace(array( 223 | 'property_path' => '['.$name.']', 224 | ), $this->getOptionsForType($type))); 225 | } 226 | 227 | // Add to final data array 228 | $finalData[$name] = $item; 229 | ++$name; 230 | } 231 | 232 | // Remove all empty rows 233 | if ($this->_allowDelete) { 234 | foreach ($form as $name => $child) { 235 | // New items will have null data. Skip these. 236 | if (!is_null($child->getData())) { 237 | $index = $this->propertyAccessor->getValue($child->getData(), $this->indexProperty); 238 | if (!isset($indexedData[$index])) { 239 | $form->remove($name); 240 | } else { 241 | $finalData[$name] = $indexedData[$index]; 242 | } 243 | } 244 | } 245 | } 246 | 247 | // Replace submitted data with new form order 248 | $event->setData($finalData); 249 | } else { 250 | // Remove all empty rows 251 | if ($this->_allowDelete) { 252 | foreach ($form as $name => $child) { 253 | if (!isset($data[$name])) { 254 | $form->remove($name); 255 | } 256 | } 257 | } 258 | 259 | // Add all additional rows 260 | if ($this->_allowAdd) { 261 | foreach ($data as $name => $value) { 262 | if (!$form->has($name)) { 263 | $type = $this->getTypeForData($value); 264 | $form->add($name, $type, array_replace(array( 265 | 'property_path' => '['.$name.']', 266 | ), $this->getOptionsForType($type))); 267 | } 268 | } 269 | } 270 | } 271 | } 272 | } 273 | -------------------------------------------------------------------------------- /Form/Type/AttachmentType.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace Infinite\FormBundle\Form\Type; 11 | 12 | use Infinite\FormBundle\Attachment\AttachmentInterface; 13 | use Infinite\FormBundle\Attachment\Uploader; 14 | use Infinite\FormBundle\Attachment\PathHelper; 15 | use Symfony\Bridge\Doctrine\ManagerRegistry; 16 | use Symfony\Component\Form\AbstractType; 17 | use Symfony\Component\Form\Extension\Core\Type\FileType; 18 | use Symfony\Component\Form\Extension\Core\Type\HiddenType; 19 | use Symfony\Component\Form\FormBuilderInterface; 20 | use Symfony\Component\Form\FormError; 21 | use Symfony\Component\Form\FormEvent; 22 | use Symfony\Component\Form\FormEvents; 23 | use Symfony\Component\Form\FormInterface; 24 | use Symfony\Component\Form\FormView; 25 | use Symfony\Component\HttpFoundation\File\UploadedFile; 26 | use Symfony\Component\OptionsResolver\OptionsResolver; 27 | 28 | class AttachmentType extends AbstractType 29 | { 30 | protected $defaultSecret; 31 | protected $doctrine; 32 | protected $pathHelper; 33 | protected $uploader; 34 | 35 | public function __construct($secret, ManagerRegistry $doctrine, PathHelper $pathHelper, Uploader $uploader) 36 | { 37 | $this->defaultSecret = $secret; 38 | $this->doctrine = $doctrine; 39 | $this->pathHelper = $pathHelper; 40 | $this->uploader = $uploader; 41 | } 42 | 43 | public function buildForm(FormBuilderInterface $builder, array $options): void 44 | { 45 | $builder 46 | ->add('file', FileType::class, ['required' => $options['required'], 'mapped' => false]) 47 | ->add('meta', HiddenType::class, ['required' => false, 'mapped' => false]) 48 | ; 49 | 50 | $builder->addEventListener(FormEvents::SUBMIT, function (FormEvent $event) use ($options) { 51 | // Requirements: 52 | // The AttachmentType is intended for files that are attached to a main item. 53 | // If there is no file, it should return null. 54 | 55 | /** @var AttachmentInterface|null $data */ 56 | $data = $event->getData(); 57 | 58 | /** @var UploadedFile $file */ 59 | $file = $event->getForm()->get('file')->getData(); 60 | $meta = $event->getForm()->get('meta')->getData(); 61 | 62 | $dataClass = $options['data_class']; 63 | 64 | if ($file !== null) { 65 | if (null !== $options['allowed_mime_types'] && !in_array($file->getMimeType(), $options['allowed_mime_types'])) { 66 | $event->getForm()->get('file')->addError(new FormError($options['invalid_mime_message'])); 67 | } elseif ($file->isValid()) { 68 | // File posted - accept the new file 69 | $data = $data ?: new $dataClass(); 70 | $this->uploader->acceptUpload($file, $data); 71 | } else { 72 | $event->getForm()->get('file')->addError(new FormError($options['file_upload_failed_message'])); 73 | } 74 | } elseif ($meta !== null) { 75 | // Preserve existing attachment data 76 | list($mac, $savedData) = explode('|', $meta, 2); 77 | 78 | if (hash_hmac('sha1', $savedData, $options['secret']) === $mac) { 79 | $postedData = json_decode(base64_decode($savedData)); 80 | $data = $data ?: new $dataClass; 81 | 82 | $data->setFilename($postedData[0]); 83 | $data->setFileHash($postedData[1]); 84 | $data->setFileSize($postedData[2]); 85 | $data->setMimeType($postedData[3]); 86 | $data->setPhysicalName($postedData[4]); 87 | } 88 | } else { 89 | $data = null; 90 | } 91 | 92 | $event->setData($data); 93 | }); 94 | } 95 | 96 | public function finishView( 97 | FormView $view, 98 | FormInterface $form, 99 | array $options 100 | ): void { 101 | $attachment = $view->vars['value']; 102 | 103 | if ($attachment && $attachment->getPhysicalName()) { 104 | $savedData = base64_encode(json_encode([ 105 | $attachment->getFilename(), 106 | $attachment->getFileHash(), 107 | $attachment->getFileSize(), 108 | $attachment->getMimeType(), 109 | $attachment->getPhysicalName() 110 | ])); 111 | 112 | $mac = hash_hmac('sha1', $savedData, $options['secret']); 113 | 114 | $view['meta']->vars['value'] = $mac.'|'.$savedData; 115 | } else { 116 | $view['meta']->vars['value'] = ''; 117 | } 118 | } 119 | 120 | public function getBlockPrefix(): string 121 | { 122 | return 'infinite_form_attachment'; 123 | } 124 | 125 | public function configureOptions(OptionsResolver $resolver): void 126 | { 127 | $resolver->setRequired(array('data_class')); 128 | $resolver->setDefaults(array( 129 | 'allowed_mime_types' => null, 130 | 'invalid_mime_message' => 'That file type is not allowed', 131 | 'file_upload_failed_message' => 'File too large', 132 | 'secret' => $this->defaultSecret, 133 | )); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /Form/Type/CheckboxGridType.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace Infinite\FormBundle\Form\Type; 11 | 12 | use Infinite\FormBundle\Form\DataTransformer\CheckboxGridTransformer; 13 | use Infinite\FormBundle\Form\Util\ChoiceListViewAdapter; 14 | use Symfony\Component\Form\AbstractType; 15 | use Symfony\Component\Form\ChoiceList\ArrayChoiceList; 16 | use Symfony\Component\Form\ChoiceList\ChoiceListInterface; 17 | use Symfony\Component\Form\ChoiceList\Factory\DefaultChoiceListFactory; 18 | use Symfony\Component\Form\ChoiceList\View\ChoiceListView; 19 | use Symfony\Component\Form\FormBuilderInterface; 20 | use Symfony\Component\Form\FormInterface; 21 | use Symfony\Component\Form\FormView; 22 | use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; 23 | use Symfony\Component\OptionsResolver\Options; 24 | use Symfony\Component\OptionsResolver\OptionsResolver; 25 | use Symfony\Component\PropertyAccess\PropertyAccess; 26 | use Symfony\Component\PropertyAccess\PropertyAccessor; 27 | use Symfony\Component\PropertyAccess\PropertyPath; 28 | 29 | /** 30 | * Provides a checkbox grid for non-Doctrine objects (rows and cols set by x/y_choices). 31 | */ 32 | class CheckboxGridType extends AbstractType 33 | { 34 | public function buildForm(FormBuilderInterface $builder, array $options): void 35 | { 36 | $accessor = PropertyAccess::createPropertyAccessor(); 37 | 38 | /** @var ChoiceListInterface $yChoiceList */ 39 | $yChoiceList = $options['y_choice_list']; 40 | 41 | foreach ($yChoiceList->getChoices() as $value => $choice) { 42 | // The $choice object itself can be used as a label if it has __toString or a label path 43 | $labelBase = $choice; 44 | 45 | // Although if we're using y_choices then look up the label there. 46 | if ($options['y_choices'] !== null && array_key_exists($choice, $options['y_choices'])) { 47 | $labelBase = $options['y_choices'][$choice]; 48 | } 49 | 50 | $this->buildRow($builder, $options, $choice, $labelBase, $accessor, $value); 51 | } 52 | 53 | $builder->addViewTransformer(new CheckboxGridTransformer($options)); 54 | } 55 | 56 | /** 57 | * @param FormBuilderInterface $builder 58 | * @param array $options 59 | * @param $choice 60 | * @param $labelBase 61 | * @param $accessor 62 | * @param $value 63 | */ 64 | protected function buildRow(FormBuilderInterface $builder, array $options, $choice, $labelBase, PropertyAccessor $accessor, $value) 65 | { 66 | $rowOptions = array( 67 | 'cell_filter' => $options['cell_filter'], 68 | 'choice_list' => $options['x_choice_list'], 69 | 'label_path' => $options['x_label_path'], 70 | 'row' => $choice, 71 | 'row_label' => $options['y_label_path'] === null ? $labelBase : $accessor->getValue($choice, $options['y_label_path']), 72 | ); 73 | 74 | $builder->add($value, CheckboxRowType::class, $rowOptions); 75 | } 76 | 77 | public function buildView(FormView $view, FormInterface $form, array $options): void 78 | { 79 | $view->vars['headers'] = $this->buildChoiceListView($options['x_choice_list'], $options['x_choices'], $options['x_label_path']); 80 | } 81 | 82 | public function getBlockPrefix(): string 83 | { 84 | return 'infinite_form_checkbox_grid'; 85 | } 86 | 87 | public function configureOptions(OptionsResolver $resolver): void 88 | { 89 | $defaultXChoiceList = function (Options $options) { 90 | if (!isset($options['x_choices'])) { 91 | throw new InvalidOptionsException('You must provide the x_choices option.'); 92 | } 93 | 94 | // Choice lists are not responsible for labels. 95 | // Strip the labels until we build the choice view later. 96 | return new ArrayChoiceList(array_keys($options['x_choices']), function ($choice) { 97 | return $choice; 98 | }); 99 | }; 100 | 101 | $defaultYChoiceList = function (Options $options) { 102 | if (!isset($options['y_choices'])) { 103 | throw new InvalidOptionsException('You must provide the y_choices option.'); 104 | } 105 | 106 | // Choice lists are not responsible for labels. 107 | return new ArrayChoiceList(array_keys($options['y_choices']), function ($choice) { 108 | return $choice; 109 | }); 110 | }; 111 | 112 | $resolver->setDefaults(array( 113 | 'class' => null, 114 | 'cell_filter' => null, 115 | 116 | 'x_choices' => null, 117 | 'x_choice_list' => $defaultXChoiceList, 118 | 'x_label_path' => null, 119 | 120 | 'y_label_path' => null, 121 | 'y_choices' => null, 122 | 'y_choice_list' => $defaultYChoiceList, 123 | )); 124 | 125 | $resolver->setRequired(array( 126 | 'x_path', 127 | 'y_path', 128 | )); 129 | } 130 | 131 | /** 132 | * @param ChoiceListInterface $choiceList 133 | * @param array|null $originalChoices 134 | * @param string|PropertyPath|null $labelPath 135 | * 136 | * @return ChoiceListView 137 | */ 138 | protected function buildChoiceListView($choiceList, $originalChoices, $labelPath) 139 | { 140 | // Build the choice list view the usual way. 141 | $accessor = PropertyAccess::createPropertyAccessor(); 142 | 143 | $choiceListFactory = new DefaultChoiceListFactory(); 144 | $labelCallback = function ($choice) use ($accessor, $originalChoices, $labelPath) { 145 | // If we stripped the choice labels back in configureOptions then look it up now. 146 | if ($originalChoices !== null && array_key_exists($choice, $originalChoices)) { 147 | $choice = $originalChoices[$choice]; 148 | } 149 | 150 | if ($labelPath === null) { 151 | return (string) $choice; 152 | } else { 153 | return $accessor->getValue($choice, $labelPath); 154 | } 155 | }; 156 | 157 | $choiceListView = $choiceListFactory->createView($choiceList, null, $labelCallback); 158 | 159 | // Wrap it in a custom class so that old form_themes can still call getRemainingViews. 160 | return new ChoiceListViewAdapter($choiceListView); 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /Form/Type/CheckboxRowType.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace Infinite\FormBundle\Form\Type; 11 | 12 | use Infinite\FormBundle\Form\EventListener\CheckboxRowCreationListener; 13 | use Symfony\Component\Form\AbstractType; 14 | use Symfony\Component\Form\FormBuilderInterface; 15 | use Symfony\Component\Form\FormInterface; 16 | use Symfony\Component\Form\FormView; 17 | use Symfony\Component\OptionsResolver\OptionsResolver; 18 | use Symfony\Component\OptionsResolver\OptionsResolverInterface; 19 | 20 | class CheckboxRowType extends AbstractType 21 | { 22 | public function buildForm(FormBuilderInterface $builder, array $options): void 23 | { 24 | // Rows added by listener (need the data to be available before creating checkboxes) 25 | $builder->addEventSubscriber(new CheckboxRowCreationListener($builder->getFormFactory())); 26 | } 27 | 28 | public function buildView(FormView $view, FormInterface $form, array $options): void 29 | { 30 | $view->vars['label'] = $options['row_label']; 31 | } 32 | 33 | public function getBlockPrefix(): string 34 | { 35 | return 'infinite_form_checkbox_row'; 36 | } 37 | 38 | public function configureOptions(OptionsResolver $resolver): void 39 | { 40 | $resolver->setDefaults(array( 41 | 'cell_filter' => null, 42 | 'choice_list' => null, 43 | 'label_path' => null, 44 | 'row' => null, 45 | 'row_label' => null, 46 | )); 47 | } 48 | 49 | // BC for SF < 2.7 50 | public function setDefaultOptions(OptionsResolverInterface $resolver) 51 | { 52 | $this->configureOptions($resolver); 53 | } 54 | 55 | // BC for SF < 2.8 56 | public function getName() 57 | { 58 | return $this->getBlockPrefix(); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Form/Type/EntityCheckboxGridType.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace Infinite\FormBundle\Form\Type; 11 | 12 | use Doctrine\ORM\Mapping\ClassMetadata; 13 | use Infinite\FormBundle\Form\Util\LegacyChoiceListUtil; 14 | use Symfony\Bridge\Doctrine\Form\ChoiceList\ORMQueryBuilderLoader; 15 | use Symfony\Bridge\Doctrine\ManagerRegistry; 16 | use Symfony\Component\Form\AbstractType; 17 | use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; 18 | use Symfony\Component\OptionsResolver\Options; 19 | use Symfony\Component\OptionsResolver\OptionsResolver; 20 | 21 | /** 22 | * Provides a checkbox grid for Doctrine entities. 23 | */ 24 | class EntityCheckboxGridType extends AbstractType 25 | { 26 | protected $registry; 27 | 28 | public function __construct(ManagerRegistry $registry) 29 | { 30 | $this->registry = $registry; 31 | } 32 | 33 | public function getBlockPrefix(): string 34 | { 35 | return 'infinite_form_entity_checkbox_grid'; 36 | } 37 | 38 | public function getParent(): string 39 | { 40 | return CheckboxGridType::class; 41 | } 42 | 43 | public function configureOptions(OptionsResolver $resolver): void 44 | { 45 | // X Axis defaults 46 | $defaultXClass = function (Options $options) { 47 | /** @var $em \Doctrine\ORM\EntityManager */ 48 | $em = $options['em']; 49 | 50 | return $em->getClassMetadata($options['class'])->getAssociationTargetClass($options['x_path']); 51 | }; 52 | 53 | $defaultXLoader = function (Options $options) { 54 | if ($options['x_query_builder'] !== null) { 55 | return new ORMQueryBuilderLoader($options['x_query_builder']); 56 | } 57 | 58 | return null; 59 | }; 60 | 61 | $defaultXChoiceList = function (Options $options) { 62 | return LegacyChoiceListUtil::createEntityChoiceList( 63 | $options['em'], 64 | $options['x_class'], 65 | $options['x_label_path'], 66 | $options['x_loader'], 67 | $options['x_choice_value'] 68 | ); 69 | }; 70 | 71 | // Y Axis defaults 72 | $defaultYClass = function (Options $options) { 73 | /** @var $em \Doctrine\ORM\EntityManager */ 74 | $em = $options['em']; 75 | 76 | return $em->getClassMetadata($options['class'])->getAssociationTargetClass($options['y_path']); 77 | }; 78 | 79 | $defaultYLoader = function (Options $options) { 80 | if ($options['y_query_builder'] !== null) { 81 | return new ORMQueryBuilderLoader($options['y_query_builder']); 82 | } 83 | 84 | return null; 85 | }; 86 | 87 | $defaultYChoiceList = function (Options $options) { 88 | return LegacyChoiceListUtil::createEntityChoiceList( 89 | $options['em'], 90 | $options['y_class'], 91 | $options['y_label_path'], 92 | $options['y_loader'], 93 | $options['y_choice_value'] 94 | ); 95 | }; 96 | 97 | $defaultXChoiceValue = function (Options $options) { 98 | /** @var ClassMetadata $xClassMetadata */ 99 | $xClassMetadata = $options['em']->getClassMetadata($options['x_class']); 100 | $ids = $xClassMetadata->getIdentifierFieldNames(); 101 | 102 | if (count($ids) !== 1) { 103 | throw new InvalidOptionsException('Could not set x_choice_value automatically. You must specify it manually.'); 104 | } 105 | 106 | return function ($object) use ($xClassMetadata) { 107 | $ids = $xClassMetadata->getIdentifierValues($object); 108 | 109 | return reset($ids); 110 | }; 111 | }; 112 | 113 | $defaultYChoiceValue = function (Options $options) { 114 | /** @var ClassMetadata $yClassMetadata */ 115 | $yClassMetadata = $options['em']->getClassMetadata($options['y_class']); 116 | $ids = $yClassMetadata->getIdentifierFieldNames(); 117 | 118 | if (count($ids) !== 1) { 119 | throw new InvalidOptionsException('Could not set y_choice_value automatically. You must specify it manually.'); 120 | } 121 | 122 | return function ($object) use ($yClassMetadata) { 123 | $ids = $yClassMetadata->getIdentifierValues($object); 124 | 125 | return reset($ids); 126 | }; 127 | }; 128 | 129 | $resolver->setDefaults(array( 130 | 'em' => null, 131 | 132 | 'x_class' => $defaultXClass, 133 | 'x_query_builder' => null, 134 | 'x_loader' => $defaultXLoader, 135 | 'x_choice_value' => $defaultXChoiceValue, 136 | 'x_choice_list' => $defaultXChoiceList, 137 | 'x_label_path' => null, 138 | 139 | 'y_class' => $defaultYClass, 140 | 'y_query_builder' => null, 141 | 'y_loader' => $defaultYLoader, 142 | 'y_choice_value' => $defaultYChoiceValue, 143 | 'y_choice_list' => $defaultYChoiceList, 144 | 'y_label_path' => null, 145 | 146 | 'cell_filter' => null, 147 | )); 148 | 149 | $resolver->setRequired(array( 150 | 'class', 151 | 'x_path', 152 | 'y_path', 153 | )); 154 | 155 | $resolver->setNormalizer('em', $this->getEntityManagerNormalizer()); 156 | } 157 | 158 | private function getEntityManagerNormalizer() 159 | { 160 | $registry = $this->registry; // for closures 161 | 162 | // Entity manager 'normaliser' - turns an entity manager name into an entity manager instance 163 | return function (Options $options, $emName) use ($registry) { 164 | if ($emName !== null) { 165 | return $registry->getManager($emName); 166 | } 167 | 168 | $em = $registry->getManagerForClass($options['class']); 169 | 170 | if ($em === null) { 171 | throw new InvalidOptionsException(sprintf('"%s" is not a Doctrine entity', $options['class'])); 172 | } 173 | 174 | return $em; 175 | }; 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /Form/Type/EntitySearchType.php: -------------------------------------------------------------------------------- 1 | transformerFactory = $transformerFactory; 24 | } 25 | 26 | public function buildForm(FormBuilderInterface $builder, array $options): void 27 | { 28 | $builder 29 | ->add('id', HiddenType::class) 30 | ->add('name', TextType::class, ['required' => $options['required']]) 31 | ->setAttribute('search_route', $options['search_route']) 32 | ->addModelTransformer($this->transformerFactory->createFromOptions($options)) 33 | ; 34 | } 35 | 36 | public function buildView(FormView $view, FormInterface $form, array $options): void 37 | { 38 | $view->vars['search_route'] = $form->getConfig()->getAttribute('search_route'); 39 | } 40 | 41 | public function getBlockPrefix(): string 42 | { 43 | return 'infinite_form_entity_search'; 44 | } 45 | 46 | public function configureOptions(OptionsResolver $resolver): void 47 | { 48 | $resolver->setDefaults([ 49 | 'allow_not_found' => false, 50 | 'error_bubbling' => false, 51 | 'invalid_message' => 'Item not found', 52 | 'name' => null, 53 | 'search_route' => null, 54 | ]); 55 | 56 | $resolver->setRequired([ 57 | 'class', 58 | ]); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Form/Type/PolyCollectionType.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace Infinite\FormBundle\Form\Type; 11 | 12 | use Infinite\FormBundle\Form\EventListener\ResizePolyFormListener; 13 | use Symfony\Component\Form\AbstractType; 14 | use Symfony\Component\Form\Exception\InvalidConfigurationException; 15 | use Symfony\Component\Form\FormBuilderInterface; 16 | use Symfony\Component\Form\FormInterface; 17 | use Symfony\Component\Form\FormTypeInterface; 18 | use Symfony\Component\Form\FormView; 19 | use Symfony\Component\OptionsResolver\Options; 20 | use Symfony\Component\OptionsResolver\OptionsResolver; 21 | use Symfony\Component\OptionsResolver\OptionsResolverInterface; 22 | 23 | /** 24 | * A collection type that will take an array of other form types 25 | * to use for each of the classes in an inheritance tree. 26 | * 27 | * The collection allows you to use the form component to manipulate 28 | * objects that have a common parent, like Doctrine's single or 29 | * multi table inheritance strategies by registering different 30 | * types for each class in the inheritance tree. 31 | */ 32 | class PolyCollectionType extends AbstractType 33 | { 34 | /** 35 | * {@inheritdoc} 36 | */ 37 | public function buildForm(FormBuilderInterface $builder, array $options): void 38 | { 39 | $prototypes = $this->buildPrototypes($builder, $options); 40 | if ($options['allow_add'] && $options['prototype']) { 41 | $builder->setAttribute('prototypes', $prototypes); 42 | } 43 | 44 | $useTypesOptions = !empty($options['types_options']); 45 | 46 | $resizeListener = new ResizePolyFormListener( 47 | $prototypes, 48 | $useTypesOptions === true ? $options['types_options'] : $options['options'], 49 | $options['allow_add'], 50 | $options['allow_delete'], 51 | $options['type_name'], 52 | $options['index_property'], 53 | $useTypesOptions 54 | ); 55 | 56 | $builder->addEventSubscriber($resizeListener); 57 | } 58 | 59 | /** 60 | * Builds prototypes for each of the form types used for the collection. 61 | * 62 | * @param \Symfony\Component\Form\FormBuilderInterface $builder 63 | * @param array $options 64 | * 65 | * @return array 66 | */ 67 | protected function buildPrototypes(FormBuilderInterface $builder, array $options) 68 | { 69 | $prototypes = array(); 70 | $useTypesOptions = !empty($options['types_options']); 71 | 72 | foreach ($options['types'] as $type) { 73 | if ($type instanceof FormTypeInterface) { 74 | @trigger_error(sprintf('Passing type instances to PolyCollection is deprecated since version 1.0.5 and will not be supported in 2.0. Use the fully-qualified type class name instead (%s).', get_class($type)), E_USER_DEPRECATED); 75 | } 76 | 77 | $typeOptions = $options['options']; 78 | if ($useTypesOptions) { 79 | $typeOptions = []; 80 | if (isset($options['types_options'][$type])) { 81 | $typeOptions = $options['types_options'][$type]; 82 | } 83 | } 84 | 85 | $prototype = $this->buildPrototype( 86 | $builder, 87 | $options['prototype_name'], 88 | $type, 89 | $typeOptions 90 | ); 91 | 92 | $key = $prototype->get($options['type_name'])->getData(); 93 | 94 | if (array_key_exists($key, $prototypes)) { 95 | throw new InvalidConfigurationException(sprintf( 96 | 'Each type of row in a polycollection must have a unique key. (Found "%s" in both %s and %s)', 97 | $key, 98 | get_class($prototypes[$key]->getConfig()->getType()->getInnerType()), 99 | get_class($prototype->getType()->getInnerType()) 100 | )); 101 | } 102 | 103 | $prototypes[$key] = $prototype->getForm(); 104 | } 105 | 106 | return $prototypes; 107 | } 108 | 109 | /** 110 | * Builds an individual prototype. 111 | * 112 | * @param \Symfony\Component\Form\FormBuilderInterface $builder 113 | * @param string $name 114 | * @param string|FormTypeInterface $type 115 | * @param array $options 116 | * 117 | * @return \Symfony\Component\Form\FormBuilderInterface 118 | */ 119 | protected function buildPrototype(FormBuilderInterface $builder, $name, $type, array $options) 120 | { 121 | $prototype = $builder->create($name, $type, array_replace(array( 122 | 'label' => $name.'label__', 123 | ), $options)); 124 | 125 | return $prototype; 126 | } 127 | 128 | /** 129 | * {@inheritdoc} 130 | */ 131 | public function buildView(FormView $view, FormInterface $form, array $options): void 132 | { 133 | $view->vars['allow_add'] = $options['allow_add']; 134 | $view->vars['allow_delete'] = $options['allow_delete']; 135 | 136 | if ($form->getConfig()->hasAttribute('prototypes')) { 137 | $view->vars['prototypes'] = array_map(function (FormInterface $prototype) use ($view) { 138 | return $prototype->createView($view); 139 | }, $form->getConfig()->getAttribute('prototypes')); 140 | } 141 | } 142 | 143 | /** 144 | * {@inheritdoc} 145 | */ 146 | public function finishView(FormView $view, FormInterface $form, array $options): void 147 | { 148 | if ($form->getConfig()->hasAttribute('prototypes')) { 149 | $multiparts = array_filter( 150 | $view->vars['prototypes'], 151 | function (FormView $prototype) { 152 | return $prototype->vars['multipart']; 153 | } 154 | ); 155 | 156 | if ($multiparts) { 157 | $view->vars['multipart'] = true; 158 | } 159 | } 160 | } 161 | 162 | /** 163 | * {@inheritdoc} 164 | */ 165 | public function getBlockPrefix(): string 166 | { 167 | return 'infinite_form_polycollection'; 168 | } 169 | 170 | /** 171 | * {@inheritdoc} 172 | */ 173 | public function configureOptions(OptionsResolver $resolver): void 174 | { 175 | $resolver->setDefaults(array( 176 | 'allow_add' => false, 177 | 'allow_delete' => false, 178 | 'prototype' => true, 179 | 'prototype_name' => '__name__', 180 | 'type_name' => '_type', 181 | 'options' => [], 182 | 'types_options' => [], 183 | 'index_property' => null, 184 | )); 185 | 186 | $resolver->setRequired(array( 187 | 'types', 188 | )); 189 | // OptionsResolver 2.6+ 190 | if (method_exists($resolver, 'setNormalizer')) { 191 | $resolver->setAllowedTypes('types', 'array'); 192 | $resolver->setNormalizer('options', $this->getOptionsNormalizer()); 193 | $resolver->setNormalizer('types_options', $this->getTypesOptionsNormalizer()); 194 | } else { 195 | $resolver->setAllowedTypes(array( 196 | 'types' => 'array', 197 | )); 198 | $resolver->setNormalizers(array( 199 | 'options' => $this->getOptionsNormalizer(), 200 | )); 201 | $resolver->setNormalizers(array( 202 | 'types_options' => $this->getTypesOptionsNormalizer(), 203 | )); 204 | } 205 | } 206 | 207 | // BC for SF < 2.7 208 | public function setDefaultOptions(OptionsResolverInterface $resolver) 209 | { 210 | $this->configureOptions($resolver); 211 | } 212 | 213 | // BC for SF < 2.8 214 | public function getName() 215 | { 216 | return $this->getBlockPrefix(); 217 | } 218 | 219 | private function getOptionsNormalizer() 220 | { 221 | return function (Options $options, $value) { 222 | $value['block_name'] = 'entry'; 223 | 224 | return $value; 225 | }; 226 | } 227 | 228 | private function getTypesOptionsNormalizer() 229 | { 230 | return function (Options $options, $value) { 231 | foreach ($options['types'] as $type) { 232 | if (isset($value[$type])) { 233 | $value[$type]['block_name'] = 'entry'; 234 | } 235 | } 236 | 237 | return $value; 238 | }; 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /Form/Util/ChoiceListViewAdapter.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace Infinite\FormBundle\Form\Util; 11 | 12 | use Symfony\Component\Form\ChoiceList\View\ChoiceListView; 13 | 14 | class ChoiceListViewAdapter extends ChoiceListView 15 | { 16 | public function __construct(ChoiceListView $wrapped) 17 | { 18 | parent::__construct($wrapped->choices, $wrapped->preferredChoices); 19 | } 20 | 21 | /** 22 | * BC for old form themes running on new Symfony. 23 | * 24 | * @deprecated use the public $choices property 25 | */ 26 | public function getRemainingViews() 27 | { 28 | return $this->choices; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Form/Util/LegacyChoiceListUtil.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace Infinite\FormBundle\Form\Util; 11 | 12 | use Doctrine\ORM\EntityManager; 13 | use Symfony\Bridge\Doctrine\Form\ChoiceList\DoctrineChoiceLoader; 14 | use Symfony\Bridge\Doctrine\Form\ChoiceList\EntityLoaderInterface; 15 | use Symfony\Component\Form\ChoiceList\ChoiceListInterface; 16 | use Symfony\Component\Form\ChoiceList\Factory\DefaultChoiceListFactory; 17 | use Symfony\Component\Form\ChoiceList\Factory\PropertyAccessDecorator; 18 | 19 | /** 20 | * @internal 21 | */ 22 | final class LegacyChoiceListUtil 23 | { 24 | /** 25 | * @param EntityManager $em 26 | * @param string $class 27 | * @param string $labelPath 28 | * @param EntityLoaderInterface|null $loader 29 | * @param callable $valueCallback 30 | * 31 | * @return ChoiceListInterface 32 | */ 33 | public static function createEntityChoiceList( 34 | EntityManager $em, 35 | $class, 36 | $labelPath, 37 | ?EntityLoaderInterface $loader, 38 | $valueCallback 39 | ) { 40 | $factory = new PropertyAccessDecorator(new DefaultChoiceListFactory()); 41 | 42 | return $factory->createListFromLoader( 43 | new DoctrineChoiceLoader($em, $class, null, $loader), 44 | $valueCallback 45 | ); 46 | } 47 | 48 | private function __construct() 49 | { 50 | } 51 | 52 | private function __clone() 53 | { 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /InfiniteFormBundle.php: -------------------------------------------------------------------------------- 1 | 5 | * 6 | * For the full copyright and license information, please view the LICENSE 7 | * file that was distributed with this source code. 8 | */ 9 | 10 | namespace Infinite\FormBundle; 11 | 12 | use Symfony\Component\HttpKernel\Bundle\Bundle; 13 | 14 | class InfiniteFormBundle extends Bundle 15 | { 16 | } 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | InfiniteFormBundle 2 | ================== 3 | 4 | A collection of useful form types and extensions for Symfony. 5 | 6 | See the [form demo project](https://github.com/infinite-networks/form-demo) 7 | for working examples. 8 | 9 | Installation 10 | ------------ 11 | 12 | Installation instructions [can be found here](Resources/doc/installation.md). 13 | 14 | PolyCollection 15 | -------------- 16 | 17 | The PolyCollection form type allows you to create a collection type 18 | on a property where the relationship is to a polymorphic object structure 19 | like Doctrine2's Single or Multi table inheritance. 20 | 21 | For example, if you had an Invoice entity that had a relationship to an 22 | entity that was using Doctrine inheritance `InvoiceLine` and you wanted 23 | to define multiple InvoiceLine types depending on what you wanted to invoice 24 | like `InvoiceProductLine`, `InvoiceShippingLine` and `InvoiceDiscountLine` 25 | you could use this form type to achieve a form collection that would support 26 | all 4 types of `InvoiceLine` inside the same collection. 27 | 28 | For more information see the [PolyCollection Documentation](Resources/doc/polycollection.md). 29 | 30 | Collection Helper 31 | ----------------- 32 | 33 | InfiniteFormBundle supplies some helper javascript for working with form collections. It 34 | supports both the standard Symfony2 collection type and the PolyCollection type supplied 35 | by this bundle. 36 | 37 | For more information see the [Collection Helper Documentation](Resources/doc/collection-helper.md). 38 | 39 | CheckboxGrid 40 | ------------ 41 | 42 | The CheckboxGrid form type allows editing many-to-many relationships with 43 | a grid of checkboxes. It has handy shortcuts for Doctrine entities but can 44 | also be used with arrays of regular objects. 45 | 46 | For example, a company might sell multiple products, and operate in 47 | different areas. Any of its salesmen could sell any combination of products 48 | in areas. The salesman form needs a table of checkboxes where the rows are 49 | products and the columns are areas (or vice versa!) 50 | 51 | For more information see the [CheckboxGrid Documentation](Resources/doc/checkboxgrid.md). 52 | 53 | EntitySearch 54 | ------------ 55 | 56 | EntitySearchType is an alternative to Symfony's built-in EntityType. Instead of 57 | loading all entities into a drop-down list, it renders a single text field that 58 | loads autocomplete suggestions through an AJAX callback. 59 | 60 | AJAX callback not included. 61 | 62 | For more information see the [EntitySearch Documentation](Resources/doc/entitysearch.md). 63 | 64 | Attachment 65 | ---------- 66 | 67 | AttachmentType is an alternative to Symfony's built-in FileType. 68 | 69 | For more information see the [Attachment Documentation](Resources/doc/attachment.md). 70 | 71 | Twig Helper 72 | ----------- 73 | 74 | InfiniteFormBundle comes with a Twig extension that adds form specific helpers 75 | for use when rendering templates. 76 | 77 | For more information see the [Twig Helper](Resources/doc/twig-helper.md). 78 | -------------------------------------------------------------------------------- /Resources/config/attachment.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | 13 | 14 | 15 | Infinite\FormBundle\Attachment\PathHelper 16 | Infinite\FormBundle\Form\Type\AttachmentType 17 | Infinite\FormBundle\Attachment\Sanitiser 18 | Infinite\FormBundle\Attachment\Streamer 19 | Infinite\FormBundle\Attachment\Uploader 20 | 21 | %kernel.secret% 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | %infinite_form.attachment.save_config% 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | %infinite_form.attachment.default_secret% 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /Resources/config/checkbox_grid.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /Resources/config/entity_search.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | 13 | 14 | 15 | Infinite\FormBundle\Form\Type\EntitySearchType 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /Resources/config/polycollection.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | 13 | 14 | 15 | Infinite\FormBundle\Form\Type\PolyCollectionType 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /Resources/config/twig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | 13 | 14 | 15 | Infinite\FormBundle\Twig\FormExtension 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /Resources/doc/attachment.md: -------------------------------------------------------------------------------- 1 | InfiniteFormBundle's Attachment Form Type 2 | ========================================= 3 | 4 | Synopsis 5 | -------- 6 | 7 | A form type that moves file uploads and remembers them even if there's a 8 | validation error elsewhere in the form. 9 | 10 | Suppose you have a Company entity with a single, optional CompanyLogo. 11 | Your CompanyLogo entity must implement AttachmentInterface 12 | (or just extend Attachment): 13 | 14 | ```php 15 | class CompanyLogo extends \Infinite\FormBundle\Attachment\Attachment 16 | { 17 | // etc 18 | } 19 | ``` 20 | 21 | And you must define a save path and filename format in your YAML configuration: 22 | 23 | ``` 24 | infinite_form: 25 | attachments: 26 | 'App\Entity\CompanyLogo': 27 | dir: '%kernel.project_dir%/var/uploads' 28 | format: 'logos/{hash(0..2)}/{name}' 29 | ``` 30 | 31 | Then you can add the logo to your form as follows: 32 | 33 | ```php 34 | $builder->add('logo', AttachmentType::class, array( 35 | 'allowed_mime_types' => ['image/jpeg', 'image/png'], 36 | 'data_class' => CompanyLogo::class, 37 | 'required' => false, 38 | )); 39 | ``` 40 | 41 | And that's it. Your form will appear with a standard file field. If you edit 42 | a company with an existing logo, the form will instead display the existing 43 | file name and a Remove button. 44 | -------------------------------------------------------------------------------- /Resources/doc/checkboxgrid.md: -------------------------------------------------------------------------------- 1 | InfiniteFormBundle's CheckboxGrid Form Type 2 | =========================================== 3 | 4 | Synopsis 5 | ------- 6 | 7 | This: 8 | 9 | ```php 10 | $builder->add('productAreas', EntityCheckboxGridType::class, array( 11 | 'class' => 'Acme\DemoBundle\Entity\SalesmanProductArea', 12 | 'x_path' => 'productSold', 13 | 'y_path' => 'areaServiced', 14 | )); 15 | ``` 16 | 17 | Becomes this: 18 | 19 | ![Rendered checkbox grid](checkboxgrid.png) 20 | 21 | Introduction 22 | ------------ 23 | 24 | The CheckboxGrid form type allows editing many-to-many relationships with 25 | a grid of checkboxes. It has handy shortcuts for Doctrine entities but can 26 | also be used with arrays of regular objects. 27 | 28 | For example, a company might sell multiple products, and operate in 29 | different areas. Any of its salesmen could sell any combination of products 30 | in areas. The salesman form needs a table of checkboxes where the rows are 31 | products and the columns are areas (or vice versa!) 32 | 33 | Requirements and Notes 34 | --------------------- 35 | 36 | * For the entity checkbox grid, you will probably need to specify 37 | `cascade={"persist"}, orphanRemoval=true`. In the example below, it would 38 | go on Salesman::productAreas. 39 | 40 | 41 | Installation 42 | ------------ 43 | 44 | * [Install InfiniteFormBundle](installation.md) 45 | * Either include the default form theme (covered in the installation docs) 46 | or define your own 47 | 48 | Usage 49 | ----- 50 | 51 | A very simple example: 52 | 53 | ```php 54 | add('name', TextType::class); 69 | $builder->add('productAreas', EntityCheckboxGrid::class, array( 70 | 'class' => 'Acme\DemoBundle\Entity\SalesmanProductArea', 71 | 'x_path' => 'productSold', 72 | 'y_path' => 'areaServiced', 73 | )); 74 | } 75 | 76 | public function getBlockPrefix() 77 | { 78 | return 'salesman'; 79 | } 80 | 81 | public function configureOptions(OptionsResolver $resolver) 82 | { 83 | $resolver->setDefaults(array( 84 | 'data_class' => 'Acme\DemoBundle\Entity\Salesman', 85 | )); 86 | } 87 | } 88 | ``` 89 | 90 | Render it normally in your form and you're done! If you don't get a table of 91 | checkboxes then make sure you're including the form theme or define your own. 92 | 93 | This example assumed that the product and area types have a __toString. If 94 | not, specify them with x_label_path and/or y_label_path. 95 | 96 | The CheckboxGrid type determines automatically (from Doctrine metadata) the 97 | types of productSold and areaServiced, then loads all of them to set the 98 | rows and columns. You can also specify a query builder or choice list. 99 | 100 | Finally, you can exclude checkboxes with a cell_filter closure. 101 | 102 | An example with more options: 103 | 104 | ```php 105 | $builder->add('productAreas', EntityCheckboxGridType::class, array( 106 | 'class' => 'Acme\DemoBundle\Entity\SalesmanProductArea', 107 | 108 | 'x_path' => 'productSold', 109 | 'x_label_path' => 'name', 110 | 'x_query_builder' => function (EntityRepository $repo) { 111 | return $repo->createQueryBuilder('p') 112 | ->orderBy('p.name'); 113 | }, 114 | 115 | 'y_path' => 'areaServiced', 116 | 'y_label_path' => 'name', 117 | 'y_choices' => $areaChoices, // An array of options built elsewhere 118 | 119 | 'cell_filter' => function ($x, $y) { 120 | // We cannot sell tables in the north due to contractual obligations 121 | return $x->getName() != 'Table' || $y->getName() != 'North'; 122 | }, 123 | )); 124 | ``` 125 | 126 | Customising the Rendering 127 | ------------------------- 128 | 129 | The default rendering is a plain table. Maybe it needs classes to match your 130 | CSS styles or you'd like to add some Javascript to allow checking every box 131 | in a row or column at once. 132 | 133 | Open your form theme and add blocks for infinite_form_checkbox_grid_widget 134 | and infinite_form_checkbox_row_widget. Use our [default form theme](../views/form_theme.html.twig) 135 | as a guide for what to put there. 136 | 137 | See [Form Theming in Twig](http://symfony.com/doc/3.1/cookbook/form/form_customization.html#form-theming-in-twig) 138 | if you're not sure where to add the blocks. 139 | -------------------------------------------------------------------------------- /Resources/doc/checkboxgrid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/infinite-networks/InfiniteFormBundle/4335fa4bd74b927508e79511de4a2319e1ef4564/Resources/doc/checkboxgrid.png -------------------------------------------------------------------------------- /Resources/doc/collection-helper.md: -------------------------------------------------------------------------------- 1 | InfiniteFormBundle's Collection Javascript 2 | ========================================== 3 | 4 | Introduction 5 | ------------ 6 | 7 | The InfiniteFormBundle provides some helper javascript that allows you to easily implement 8 | prototype handling and adding and removing rows from the collection. The javascript 9 | library also implements support for our PolyCollection formtype. 10 | 11 | Requirements 12 | ------------ 13 | 14 | Our collections.js helper requires jQuery 1.8 or greater. 15 | 16 | Installation 17 | ------------ 18 | 19 | Include the source file into your javascript includes. This can be achieved by using 20 | assetic or any other method supported by Symfony2 or your chosen templating method. 21 | 22 | ```html+jinja 23 | {% javascripts 24 | 'components/jquery/jquery.js' 25 | 'bundles/infiniteform/js/collections.js' 26 | output='js/application.js' 27 | %} 28 | 29 | {% endjavascripts %} 30 | ``` 31 | 32 | Basic Use 33 | --------- 34 | 35 | To use the library, you must initialise it for each collection you wish to enhance. The 36 | easiest method to achieve this is to run a snippet of code on DOMReady, but you can 37 | customise the initialisation to suit your own environment. 38 | 39 | At this time, there is no simple method to activate this helper without adding additional 40 | markup to your forms. You will need to customise the templates for each of your collection 41 | types to add additional classes to target. 42 | 43 | Add our `collection_theme.html.twig` template to your twig form theme templates: (note 44 | that we do slightly modify the collection block to add an 'add' link instead of adding 45 | the prototype to the collection div. Be aware this might break other collections in your 46 | application!) 47 | 48 | ```yaml 49 | # app/config/config.yml 50 | twig: 51 | form: 52 | resources: 53 | - 'InfiniteFormBundle::collection_theme.html.twig' 54 | - '::form_theme.html.twig' 55 | ``` 56 | 57 | Once you've made this change, you can add something like the example below to initialise 58 | the collection helper. This snippet is limited by the default template scheme and will 59 | only find prototype buttons or links that are siblings to the element with the attribute 60 | `data-form-widget`. For a real implementation, it makes more sense to customise each 61 | collection's theme blocks to cater for specific requirements of each collection's needs. 62 | 63 | ```js 64 | $(function () { 65 | $('[data-form-widget=collection]').each(function () { 66 | new window.infinite.Collection(this, $(this).siblings('[data-prototype]')); 67 | }); 68 | }); 69 | ``` 70 | 71 | Full Example 72 | ------------ 73 | 74 | Consider a Customer object that contains an array of email addresses. We would define the 75 | form type to look something like: 76 | 77 | ```php 78 | public function buildForm(FormBuilderInterface $builder, array $options) 79 | { 80 | // ... 81 | $builder->add('emails', 'collection', array( 82 | 'type' => 'email', 83 | 'allow_add' => true, 84 | 'allow_delete' => true, 85 | 'prototype' => true, 86 | )); 87 | // ... 88 | } 89 | ``` 90 | 91 | Provided we've added the collection_theme.html.twig template to our twig form 92 | configuration our template could potentially look like: 93 | 94 | ```html+jinja 95 | {% form_theme form.emails _self %} 96 | 97 | {% block body %} 98 | {# ... #} 99 | {{ form_row(form.emails) }} 100 | {# ... #} 101 | {% endblock body %} 102 | 103 | {% block _form_name_emails_entry_row %} 104 |
105 | {{ form_widget(form) }} 106 | 107 |
108 | {% endblock _form_name_emails_entry_row %} 109 | ``` 110 | 111 | With the javascript included and the snippet supplied above added to your application 112 | javascript, the add and remove buttons will automatically add or remove email input fields 113 | to the collection when pressed. 114 | 115 | Options 116 | ------- 117 | 118 | There are options provided by the helper that let you configure how it finds information 119 | or behaves in certain circumstances. These options are passed to the 3rd argument of the 120 | collection helper's constructor. 121 | 122 | | *Option* | *Default Value* | *Description* | 123 | | ------------------ | --------------- | ----------------------------------------------- | 124 | | allowAdd | true | Used to indicate to the collection if rows can be added. If false, the helper will not add new rows to the collection. This will not hide any prototype buttons already present in the DOM. | 125 | | allowDelete | true | Used to indicate to the collection if rows can be removed. If false, the helper will not remove rows from the collection. Note that this will not remove (or add) any DOM elements to trigger deletion of rows. | 126 | | itemSelector | .item | The selector used to indicate a single row in the collection. In your templates, each item of the collection must have a selector to identify it as a collection item. | 127 | | prototypeAttribute | data-prototype | The attribute where the prototype html is stored on each prototype button/link. | 128 | | prototypeName | \_\_name\_\_ | A Symfony2 Form component specific option: the name used for each prototype. It defaults to \_\_name\_\_ and unless changed in the form framework, does not need to be modified. | 129 | | removeSelector | .remove_item | The selector to target a remove item button against each row. When clicked, the collection helper will remove the row from the DOM. | 130 | | keepScripts | false | Whether or not to keep script tags in the added prototype html. Set it to true if your added form element has scripts attached to it (to instantiate a wysiwyg editor for example). | 131 | 132 | Events 133 | ------ 134 | 135 | ### infinite_collection_add 136 | 137 | This event is fired when a new item is about to be added to a collection. It provides 138 | a way to modify the prototype to pre-fill information, stop the item from being added, 139 | change where in the collection the item should be added or otherwise modify how the row 140 | should be handled. This event does not fire if the collection's allowAdd option is false. 141 | 142 | If you prevent the default action from occuring, you will need to manually handle adding 143 | the row to the collection. 144 | 145 | Properties provided on the event object: 146 | 147 | - `collection`: The window.infinite.Collection instance 148 | - `$triggeredPrototype`: this is the dom element that triggered the adding of an item to 149 | the collection. In the case of a normal collection type, the 150 | prototype will be the add button. In the case of the 151 | Polycollection, the prototype will be one of the prototype 152 | buttons. 153 | - `$row`: the jQuery wrapped DOM elements that will end up being added to the collection 154 | once the event finishes. 155 | - `insertBefore`: if set by an event listener, the row will be inserted before this dom 156 | element. 157 | 158 | ### infinite_collection_remove 159 | 160 | This event is fired before a row is to be removed from the DOM. This event does not fire 161 | if the collections allowDelete option is false. 162 | 163 | If the default action is prevented, the row will not be removed and you will have to 164 | manually handle removal from the DOM. 165 | 166 | Properties provided on the event object: 167 | 168 | - `collection`: The window.infinite.Collection instance 169 | 170 | ### infinite_collection_prototype 171 | 172 | The prototype event is fired when building the prototype HTML from the data attribute that 173 | stores the prototype HTML in string form. 174 | 175 | This event allows custom behavior or modification to the prototype if required. It mostly 176 | useful for modifying the label values when adding new items. 177 | 178 | If the default action is prevented, the prototype name and label are not replaced 179 | automatically and must be handled manually. 180 | 181 | The HTML string set at `event.html` will be used for generating the new item. If you wish 182 | to change it, modifying the value on the event will change the HTML used. 183 | 184 | Properties provided on the event object: 185 | 186 | - `collection`: The window.infinite.Collection instance 187 | - `$triggeredPrototype`: this is the dom element that triggered the adding of an item to 188 | the collection. In the case of a normal collection type, the 189 | prototype will be the add button. In the case of the 190 | PolyCollection, the prototype will be one of the prototype 191 | buttons. 192 | - `html`: The raw HTML to be used for generating the prototype. It should remain as a 193 | string of HTML. The helper will process this HTML in a later stage into the DOM. 194 | - `replacement`: READ ONLY. What the prototype name should be replaced with. The helper 195 | will generate the next integer based on its internal count of items in 196 | the collection. If you wish to do a custom replacement it will need to 197 | be applied directly to `event.html`. 198 | -------------------------------------------------------------------------------- /Resources/doc/entitysearch.md: -------------------------------------------------------------------------------- 1 | InfiniteFormBundle's EntitySearch Form Type 2 | =========================================== 3 | 4 | Synopsis 5 | -------- 6 | 7 | ```php 8 | $builder->add('customer', EntitySearchType::class, array( 9 | 'class' => Customer::class, 10 | 'name' => 'fullName', 11 | 'attr' => ['placeholder' => 'Start typing to search for a customer ...'], 12 | 'search_route' => 'customer_search_json', 13 | )); 14 | ``` 15 | 16 | This appears as a normal text field with autocompletion. 17 | 18 | You must add js-autocomplete as a dependency and include 19 | bundles/infiniteform/js/entity-search.js to enable the autocomplete feature. 20 | 21 | You must define the search_route yourself: 22 | 23 | ```php 24 | /** 25 | * @Route("/customer/search.json", name="customer_search_json") 26 | * 27 | * @return JsonResponse 28 | */ 29 | public function customerSearchJson(Request $request) 30 | { 31 | $query = $request->query->get('query'); 32 | 33 | /** @var EntityRepository $repo */ 34 | $repo = $this->getDoctrine()->getRepository(Customer::class); 35 | 36 | $qb = $repo->createQueryBuilder('c'); 37 | $qb->andWhere('c.name LIKE :match'); 38 | $qb->setParameter('match', '%' . strtr($query, [' ' => '%']) . '%'); 39 | $qb->setMaxResults(20); 40 | 41 | $results = []; 42 | 43 | foreach ($qb->getQuery()->getResult() as $customer) { 44 | /** @var Customer $customer */ 45 | $results[] = [ 46 | 'id' => $customer->getId(), 47 | 'name' => $customer->getName(), 48 | ]; 49 | } 50 | 51 | return new JsonResponse(['results' => $results]); 52 | } 53 | ``` 54 | 55 | Additional notes 56 | ---------------- 57 | 58 | EntitySearchType field includes a visible text field and a hidden ID field. 59 | The hidden ID field is set whenever the user selects an item from the 60 | autocomplete dropdown, and is unset whenever the user types in the text field. 61 | 62 | If the user types in some text and submits the form without clicking on an 63 | item, EntitySearchType will search for an exact match using the field 64 | specified in the 'name' option. 65 | 66 | entity-search.js is intentionally very simple. For more advanced uses it will 67 | be necessary to write custom integration code. See entity-search.js for ideas. 68 | -------------------------------------------------------------------------------- /Resources/doc/installation.md: -------------------------------------------------------------------------------- 1 | Installing InfiniteFormBundle 2 | ============================= 3 | 4 | The installation of this bundle is handled by the use of `composer`: 5 | 6 | ``` bash 7 | $ php composer.phar require infinite-networks/form-bundle 8 | ``` 9 | 10 | Once composer has added InfiniteFormBundle to composer.json and downloaded 11 | it, you will need to add the bundle to your kernel: 12 | 13 | ``` php 14 | product->getDescription(); 88 | } 89 | 90 | public function setDescription() 91 | { 92 | // Do nothing. We get the description from the relationship. 93 | } 94 | } 95 | ``` 96 | 97 | Form Types 98 | ---------- 99 | 100 | Given our object hierarchy contains a common ancestor that has default fields to 101 | display we can define the basic fields in a common FormType. 102 | 103 | All FormTypes defined for use with the PolyCollection must contain an additional 104 | unmapped field which by default is called _type that has a default data value of 105 | the form's name. This is used internally when data is posted back to the 106 | PolyCollection so we know what kind of object must be created for new data. 107 | 108 | In our examples we assume that each FormType has been registered with the container. 109 | 110 | **Note:** The Collection FormTypes must set both a data_class and model_class option for the 111 | PolyCollection to know which type to use when it encounters an object. 112 | 113 | ```php 114 | add('customer', EntityType::class, array( /* ... */ )); 131 | $builder->add('address', EntityType::class, array( /* ... */ )); 132 | 133 | $builder->add('lines', PolyCollectionType::class, array( 134 | 'types' => array( 135 | InvoiceLineType::class, 136 | InvoiceProductLineType::class, 137 | ), 138 | 'types_options' => array( 139 | InvoiceLineType::class => array( 140 | // Here you can optionally define options for the InvoiceLineType 141 | ), 142 | InvoiceProductLineType::class => array( 143 | // Here you can optionally define options for the InvoiceProductLineType 144 | ) 145 | ), 146 | 'allow_add' => true, 147 | 'allow_delete' => true, 148 | )); 149 | } 150 | 151 | public function configureOptions(OptionsResolver $resolver) 152 | { 153 | $resolver->setDefaults(array('data_class' => Invoice::class)); 154 | } 155 | 156 | public function getBlockPrefix() 157 | { 158 | return 'invoice'; 159 | } 160 | } 161 | ``` 162 | 163 | ```php 164 | add('quantity', NumberType::class); 183 | $builder->add('unitAmount', TextType::class); 184 | $builder->add('description', TextareaType::class); 185 | 186 | $builder->add('_type', HiddenType::class, array( 187 | 'data' => 'line', // Arbitrary, but must be distinct 188 | 'mapped' => false 189 | )); 190 | } 191 | 192 | public function configureOptions(OptionsResolver $resolver) 193 | { 194 | $resolver->setDefaults(array( 195 | 'data_class' => InvoiceLine::class, 196 | 'model_class' => InvoiceLine::class, 197 | )); 198 | } 199 | 200 | public function getBlockPrefix() 201 | { 202 | return 'invoice_line'; 203 | } 204 | } 205 | ``` 206 | 207 | ```php 208 | add('quantity', NumberType::class); 226 | 227 | $builder->add('product', EntityType::class, array( 228 | // entity field definition here 229 | )); 230 | 231 | $builder->add('_type', HiddenType::class, array( 232 | 'data' => 'product', // Arbitrary, but must be distinct 233 | 'mapped' => false 234 | )); 235 | } 236 | 237 | public function configureOptions(OptionsResolver $resolver) 238 | { 239 | $resolver->setDefaults(array( 240 | 'data_class' => InvoiceProductLine::class, 241 | 'model_class' => InvoiceProductLine::class, 242 | )); 243 | } 244 | 245 | public function getBlockPrefix() 246 | { 247 | return 'invoice_product_line'; 248 | } 249 | } 250 | ``` 251 | 252 | Rendering the form 253 | ------------------ 254 | 255 | PolyCollections require manual work to render. This code can go 256 | in the same template that renders the rest of the form. 257 | 258 | You will need to render add buttons from the prototypes array, which is 259 | keyed on the _type field in the form definition. 260 | 261 | It is best illustrated by example. 262 | 263 | ```twig 264 | {# AppBundle:Invoice:add.html.twig #} 265 | 266 | {% form_theme form.lines _self %} 267 | 268 | {# ... #} 269 | 270 | {% block infinite_form_polycollection_row %} 271 | {% set collectionForm = form %} 272 |
273 |
274 |
275 |
276 | {{ form_label(collectionForm, 'Invoice lines') }} 277 |
278 |
279 | {% set form = prototypes.line %} 280 | 282 | Freight line 283 | 284 | {% set form = prototypes.product %} 285 | 287 | 288 | 289 |
290 |
291 |
292 | {% for form in collectionForm %} 293 | {{ block('entry_row') }} 294 | {% endfor %} 295 |
296 |
297 | {% endblock %} 298 | 299 | {% block entry_row %} 300 |
301 |
302 | {{ form_widget(form) }} 303 |
304 | {% endblock %} 305 | 306 | {% block invoice_line_widget %} 307 |
308 |
{{ form_row(form.description) }}
309 |
{{ form_row(form.unitAmount) }}
310 |
{{ form_row(form.quantity) }}
311 |
312 |
313 | 314 | Remove 315 | 316 |
317 |
318 | {{ form_rest(form) }} 319 | {% endblock %} 320 | 321 | {% block invoice_product_line_widget %} 322 |
323 |
{{ form_row(form.product) }}
324 |
{{ form_row(form.quantity) }}
325 |
326 |
327 | 328 | Remove 329 | 330 |
331 |
332 | {{ form_rest(form) }} 333 | {% endblock %} 334 | ``` 335 | -------------------------------------------------------------------------------- /Resources/doc/twig-helper.md: -------------------------------------------------------------------------------- 1 | InfiniteFormBundle's Twig Helper 2 | ================================ 3 | 4 | isValid test 5 | ------------ 6 | 7 | The helper provides a validity test that can be used in templates. It can be used for 8 | something like the following: 9 | 10 | ```html+jinja 11 |
12 | {{ form_row(form.field) }} 13 |
14 | ``` 15 | -------------------------------------------------------------------------------- /Resources/meta/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT license 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Resources/public/js/collections.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file is part of the InfiniteFormBundle package. 3 | * 4 | * (c) Infinite Networks Pty Ltd 5 | */ 6 | 7 | /** 8 | * Provides helper javascript for handling adding and removing items from a form 9 | * collection. It requires jQuery to operate. 10 | */ 11 | 12 | let $ = require('jquery'); 13 | 14 | window.infinite = window.infinite || {}; 15 | 16 | /** 17 | * For most use cases, the following code can initialize a collection for you 18 | * if you put a class name of "collection-autoinitialize" on it. 19 | * 20 | * 21 | * 22 | * 23 | * 24 | * 25 | * ... 26 | * ... 27 | * 28 | *
Add
29 | * 30 | */ 31 | $(document.documentElement).on('focus', '.collection-autoinitialize .add_item, .collection-autoinitialize .remove_item', function() { 32 | const coll = $(this).closest('.collection'); 33 | 34 | if (coll.hasClass('collection-autoinitialize')) { 35 | new infinite.Collection( 36 | coll.children('.items'), 37 | coll.children(':not(.items)').find('.add_item'), 38 | { 39 | itemSelector: '.item', 40 | prototypeName: coll.attr('data-prototype-name'), 41 | removeSelector: coll.attr('data-remove-selector') || '> .item > .remove_item, > .item > td > .remove_item' 42 | } 43 | ); 44 | 45 | coll.removeClass('collection-autoinitialize'); 46 | } 47 | }); 48 | 49 | 50 | /** 51 | * Creates a new collection object. 52 | * 53 | * @param collection The DOM element passed here is expected to be a reference to the 54 | * containing element that wraps all items. 55 | * @param prototypes We expect a jQuery array passed here that will provide one or 56 | * more clickable elements that contain a prototype to be inserted 57 | * into the collection as a data-prototype attribute. 58 | * @param options Allows configuration of different aspects of the Collection 59 | * objects behavior. 60 | */ 61 | window.infinite.Collection = function (collection, prototypes, options) { 62 | this.$collection = $(collection); 63 | this.internalCount = this.$collection.children().length; 64 | this.$prototypes = prototypes; 65 | 66 | this.options = $.extend({ 67 | allowAdd: true, 68 | allowDelete: true, 69 | disabledSelector: '[data-disabled]', 70 | itemSelector: '.item', 71 | prototypeAttribute: 'data-prototype', 72 | prototypeName: '__name__', 73 | removeSelector: '.remove_item', 74 | keepScripts: false 75 | }, options || {}); 76 | 77 | this.initialise(); 78 | }; 79 | 80 | window.infinite.Collection.prototype = { 81 | /** 82 | * Sets up the collection and its prototypes for action. 83 | */ 84 | initialise: function () { 85 | var that = this; 86 | this.$prototypes.on('click', function (e) { 87 | e.preventDefault(); 88 | 89 | that.addToCollection($(this)); 90 | }); 91 | 92 | this.$collection.on('click', this.options.removeSelector, function (e) { 93 | e.preventDefault(); 94 | 95 | that.removeFromCollection($(this).closest(that.options.itemSelector)); 96 | }); 97 | 98 | this.$collection.data('collection', this); 99 | }, 100 | 101 | /** 102 | * Adds another row to the collection 103 | */ 104 | addToCollection: function ($prototype) { 105 | if (!this.options.allowAdd || this.$collection.is(this.options.disabledSelector)) { 106 | return; 107 | } 108 | 109 | var html = this._getPrototypeHtml($prototype, this.internalCount++), 110 | $row = $($.parseHTML(html, document, this.options.keepScripts)); 111 | 112 | var event = this._createEvent('infinite_collection_add'); 113 | event.$triggeredPrototype = $prototype; 114 | event.$row = $row; 115 | event.insertBefore = null; 116 | this.$collection.trigger(event); 117 | 118 | if (!event.isDefaultPrevented()) { 119 | if (event.insertBefore) { 120 | $row.insertBefore(event.insertBefore); 121 | } else { 122 | this.$collection.append($row); 123 | } 124 | 125 | return $row; 126 | } 127 | }, 128 | 129 | /** 130 | * Removes a supplied row from the collection. 131 | */ 132 | removeFromCollection: function ($row) { 133 | if (!this.options.allowDelete || this.$collection.is(this.options.disabledSelector)) { 134 | return; 135 | } 136 | 137 | var event = this._createEvent('infinite_collection_remove'); 138 | $row.trigger(event); 139 | 140 | if (!event.isDefaultPrevented()) { 141 | $row.remove(); 142 | } 143 | }, 144 | 145 | /** 146 | * Retrieves the HTML from the prototype button, replacing __name__label__ 147 | * and __name__ with the supplied replacement value. 148 | * 149 | * @private 150 | */ 151 | _getPrototypeHtml: function ($prototype, replacement) { 152 | var event = this._createEvent('infinite_collection_prototype'); 153 | event.$triggeredPrototype = $prototype; 154 | event.html = $prototype.attr(this.options.prototypeAttribute); 155 | event.replacement = replacement; 156 | this.$collection.trigger(event); 157 | 158 | if (!event.isDefaultPrevented()) { 159 | var labelRegex = new RegExp(this.options.prototypeName + 'label__', 'gi'), 160 | prototypeRegex = new RegExp(this.options.prototypeName, 'gi'); 161 | 162 | event.html = event.html.replace(labelRegex, replacement) 163 | .replace(prototypeRegex, replacement); 164 | } 165 | 166 | return event.html; 167 | }, 168 | 169 | /** 170 | * Creates a jQuery event object with the given name. 171 | * 172 | * @private 173 | */ 174 | _createEvent: function (eventName) { 175 | var event = $.Event(eventName); 176 | event.collection = this; 177 | 178 | return event; 179 | } 180 | }; 181 | -------------------------------------------------------------------------------- /Resources/public/js/entity-search.js: -------------------------------------------------------------------------------- 1 | var autoComplete = require('js-autocomplete/auto-complete.min.js'); 2 | var $ = require('jquery'); 3 | 4 | function htmlEscape(s) { 5 | return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); 6 | } 7 | 8 | $(document.documentElement).on('focus', '.entity-search', function () { 9 | var that = $(this); 10 | 11 | if (that.data('autocomplete')) { 12 | return; 13 | } 14 | 15 | that.on('input', function () { 16 | that.prev().val(''); 17 | }); 18 | 19 | that.data('autocomplete', new autoComplete({ 20 | selector: this, 21 | minChars: 2, 22 | source: function source(query, callback) { 23 | query = query.toLowerCase(); 24 | $.get(that.attr('data-search-url') + '?query=' + encodeURIComponent(query), function (response) { 25 | callback(response.results); 26 | }); 27 | }, 28 | renderItem: function renderItem(result, query) { 29 | query = query.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); 30 | var re = new RegExp("(" + query.trim().split(' ').join('|') + ")", "gi"); 31 | return '
' + htmlEscape(result.list_text || result.name).replace(re, "$1") + '' + '
'; 32 | }, 33 | onSelect: function onSelect(e, search, suggestion) { 34 | e.preventDefault(); // If the user pressed enter, don't submit the form 35 | 36 | var term = JSON.parse($(suggestion).attr('data-json')); 37 | that.prev().val(term.id); 38 | that.val(term.name); 39 | that.trigger('entityselected', term); 40 | } 41 | })); 42 | }); 43 | -------------------------------------------------------------------------------- /Resources/views/collection_table_theme.html.twig: -------------------------------------------------------------------------------- 1 | {# 2 | 3 | (c) Infinite Networks 4 | 5 | For the full copyright and license information, please view the LICENSE 6 | file that was distributed with this source code. 7 | 8 | #} 9 | 10 | {# A generic theme for collections that renders them as a table. #} 11 | {% block collection_widget %} 12 | {%- set collectionForm = form -%} 13 | {%- set classes = [attr.class|default(''), 'table collection'] -%} 14 | {%- if auto_initialize is not defined or auto_initialize -%}{%- set classes = classes|merge(['collection-autoinitialize']) -%}{%- endif -%} 15 | {%- set attr = attr|merge({class: classes | join(' ') | trim}) -%} 16 | {%- if prototype_name is defined -%} 17 | {%- set attr = attr|merge({'data-prototype-name': prototype_name}) -%} 18 | {%- endif -%} 19 | {%- if disabled -%} 20 | {%- set attr = attr|merge({ 21 | 'data-disabled': 1 22 | }) -%} 23 | {%- endif -%} 24 | 25 | 26 | {{ cols | default('') }} 27 | 28 | {% if prototype.vars.compound %} 29 | {% for field in prototype %} 30 | 31 | {% endfor %} 32 | {% else %} 33 | 34 | {% endif %} 35 | {% if allow_delete %} 36 | 37 | {% endif %} 38 | 39 | 40 | {%- for form in collectionForm -%} 41 | {{ block('collection_item') }} 42 | {%- endfor -%} 43 | 44 | {% if allow_add %} 45 | {% set form = prototype %} 46 | 47 | 48 | 49 | 50 | {% endif %} 51 |
{{ form_label(field) | striptags }}{{ label|default(form_label(collectionForm) | striptags) }}Delete
52 | {% endblock %} 53 | 54 | {% block collection_item -%} 55 | 56 | {% if form.vars.compound %} 57 | {% for field in form %} 58 | 59 | {{ form_row(field, {label: false}) }} 60 | 61 | {% endfor %} 62 | {% else %} 63 | 64 | {{ form_row(form, {label: false}) }} 65 | 66 | {% endif %} 67 | {% if form.parent.vars.allow_delete %} 68 | 69 | 70 | 71 | {% endif %} 72 | 73 | {%- endblock %} 74 | -------------------------------------------------------------------------------- /Resources/views/collection_theme.html.twig: -------------------------------------------------------------------------------- 1 | {# 2 | 3 | (c) Infinite Networks 4 | 5 | For the full copyright and license information, please view the LICENSE 6 | file that was distributed with this source code. 7 | 8 | #} 9 | 10 | {# 11 | 12 | Provides legacy theme templates for the collection widget that will allow the use 13 | of the collection.js helper. 14 | 15 | #} 16 | 17 | {% block collection_widget -%} 18 | {% set attr = attr|merge({ 19 | 'data-form-widget': 'collection', 20 | }) %} 21 | {% if disabled %} 22 | {% set attr = attr|merge({ 23 | 'data-disabled': 1 24 | }) %} 25 | {% endif %} 26 | 27 | {{ block('form_widget') }} 28 | {% if prototype is defined %} 29 | Add 30 | {% endif %} 31 | {%- endblock collection_widget %} 32 | -------------------------------------------------------------------------------- /Resources/views/form_theme.html.twig: -------------------------------------------------------------------------------- 1 | {# 2 | 3 | (c) Infinite Networks 4 | 5 | For the full copyright and license information, please view the LICENSE 6 | file that was distributed with this source code. 7 | 8 | #} 9 | 10 | {% block infinite_form_checkbox_grid_widget %} 11 | 12 | 13 | 14 | 15 | {% for h in form.vars.headers.remainingViews %} 16 | 17 | {% endfor %} 18 | 19 | 20 | {% for row in form %} 21 | {{ form_widget(row) }} 22 | {% endfor %} 23 |
{{ h.label }}
24 | {% endblock infinite_form_checkbox_grid_widget %} 25 | 26 | {% block infinite_form_checkbox_row_widget %} 27 | 28 | {{ form.vars.label }} 29 | {% for cell in form %} 30 | {{ form_widget(cell) }} 31 | {% endfor %} 32 | 33 | {% endblock infinite_form_checkbox_row_widget %} 34 | 35 | {% block infinite_form_entity_search_widget %} 36 | {{ form_widget(form.id) }} 37 | {% set class = attr.class|default('') ~ ' entity-search' %} 38 | {{ form_widget(form.name, {'attr': attr|merge({'autocomplete': 'off', 'class': class, 'data-search-url': path(search_route)})}) }} 39 | {% endblock infinite_form_entity_search_widget %} 40 | 41 | {% block infinite_form_attachment_widget %} 42 | {% set has_att = not not form.meta.vars.value %} 43 | {# If there was a file upload error, pretend there's no attachment #} 44 | {% if form.file.vars.errors|length %} 45 | {% set has_att = false %} 46 | {% endif %} 47 | {{ form_widget(form.meta, has_att ? {} : {value: ''}) }} 48 | {{ value ? value.filename : '' }} 49 |