├── .gitignore ├── templates └── components │ ├── PageSize.html.twig │ ├── Filter.html.twig │ ├── SortLink.html.twig │ └── Table.html.twig ├── src ├── WeDevelopUXTableBundle.php ├── Twig │ ├── Component │ │ ├── PageSize.php │ │ ├── Filter.php │ │ ├── SortLink.php │ │ └── Table.php │ ├── SortExtension.php │ └── OpenerExtension.php ├── Security │ ├── OpenerSigner.php │ └── Opener.php ├── DataProvider │ ├── DataProviderInterface.php │ └── DoctrineORMProvider.php ├── Table │ ├── TableInterface.php │ └── AbstractTable.php ├── DependencyInjection │ ├── Configuration.php │ └── WeDevelopUXTableExtension.php └── Form │ └── PageSizeBuilder.php ├── assets ├── src │ └── controller.js └── package.json ├── composer.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | -------------------------------------------------------------------------------- /templates/components/PageSize.html.twig: -------------------------------------------------------------------------------- 1 | 2 | {{ form_widget(this.table.formView.pageSize, this.variables | merge({'attr': {'form': 'wedevelop-ux-table-' ~ this.table.name}})) }} 3 | 4 | -------------------------------------------------------------------------------- /templates/components/Filter.html.twig: -------------------------------------------------------------------------------- 1 | 2 | {{ form_widget(this.table.formView.filter[this.filter], this.variables | merge({'attr': {'form': 'wedevelop-ux-table-' ~ this.table.name}})) }} 3 | 4 | -------------------------------------------------------------------------------- /src/WeDevelopUXTableBundle.php: -------------------------------------------------------------------------------- 1 | 2 | 3 |
{{ this.title }}
4 |
5 |
6 | 7 | -------------------------------------------------------------------------------- /src/Twig/Component/PageSize.php: -------------------------------------------------------------------------------- 1 | secret); 15 | } 16 | 17 | public function verify(string $openerUrl, string $signature): bool 18 | { 19 | return hash_equals($this->sign($openerUrl), $signature); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@wedevelopnl/ux-table", 3 | "description": "", 4 | "license": "MIT", 5 | "version": "0.1.0", 6 | "symfony": { 7 | "controllers": { 8 | "ux-table": { 9 | "main": "src/controller.js", 10 | "webpackMode": "eager", 11 | "fetch": "eager", 12 | "enabled": true 13 | } 14 | } 15 | }, 16 | "peerDependencies": { 17 | "@hotwired/stimulus": "^3.0.0", 18 | "stimulus-use": "^0.50.0" 19 | }, 20 | "devDependencies": { 21 | "@hotwired/stimulus": "^3.0.0", 22 | "stimulus-use": "^0.50.0" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/DataProvider/DataProviderInterface.php: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | {% block content %}{% endblock %} 5 | {{ form_start(table.formView, {'attr': {'id': 'wedevelop-ux-table-' ~ this.table.name}}) }} 6 | {% for field in this.preservationForm %} 7 | {{ form_widget(field) }} 8 | {% endfor %} 9 | {{ form_end(table.formView) }} 10 | 11 |
12 | 13 | -------------------------------------------------------------------------------- /src/Table/TableInterface.php: -------------------------------------------------------------------------------- 1 | =8.1", 19 | "symfony/form": ">5.4", 20 | "symfony/routing": ">5.4", 21 | "symfony/ux-twig-component": ">2", 22 | "twig/twig": ">2", 23 | "knplabs/knp-paginator-bundle": ">5.4" 24 | }, 25 | "conflict": { 26 | "knplabs/knp-paginator-bundle": ">=6.0 <6.7" 27 | }, 28 | "suggest": { 29 | "symfony/webpack-encore-bundle": "to use the Stimulus assets included in this bundle", 30 | "symfony/ux-turbo": "*" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | getRootNode() 17 | ->children() 18 | ->booleanNode('use_default_orm_provider') 19 | ->info('Disable this if you do not have Doctrine ORM installed (eg, using Mongo).') 20 | ->defaultTrue() 21 | ->end() 22 | ->arrayNode('opener') 23 | ->addDefaultsIfNotSet() 24 | ->children() 25 | ->scalarNode('secret')->defaultValue('ThisIsNotSoSecret')->end() 26 | ->end() 27 | ->end() 28 | ->end() 29 | ; 30 | 31 | return $treeBuilder; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Security/Opener.php: -------------------------------------------------------------------------------- 1 | url = $url; 15 | $this->source = $source; 16 | $this->signature = $signature; 17 | } 18 | 19 | public static function generate(string $url, string $source, OpenerSigner $openerSigner) 20 | { 21 | return new self($url, $source, $openerSigner->sign($url)); 22 | } 23 | 24 | public static function fromBase64(string $base64): self 25 | { 26 | [$url, $source, $signature] = explode(':', base64_decode($base64)); 27 | return new self($url, $source, $signature); 28 | } 29 | 30 | public function getUrl(): string 31 | { 32 | return $this->url; 33 | } 34 | 35 | public function getSource(): string 36 | { 37 | return $this->source; 38 | } 39 | 40 | public function isValid(OpenerSigner $openerSigner): bool 41 | { 42 | return $openerSigner->verify($this->url, $this->signature); 43 | } 44 | 45 | public function toBase64(): string 46 | { 47 | return base64_encode($this->url . ':' . $this->source . ':' . $this->signature); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Twig/Component/Table.php: -------------------------------------------------------------------------------- 1 | requestStack->getCurrentRequest(); 30 | $builder = $this->formFactory->createNamedBuilder('', options: ['csrf_protection' => false]); 31 | 32 | $this->buildPreservationForm($builder, $request->query->all(), null); 33 | 34 | return $builder->getForm()->createView(); 35 | } 36 | 37 | public function buildPreservationForm(FormBuilderInterface $builder, array $params, ?string $parentKey) 38 | { 39 | foreach ($params as $key => $value) { 40 | if ($key === $this->table->getName() && $parentKey === null) { 41 | continue; 42 | } 43 | 44 | if (is_array($value)) { 45 | $nestedBuilder = $builder->create( 46 | $key, 47 | FormType::class, 48 | ['label' => false, 'csrf_protection' => false] 49 | ); 50 | $this->buildPreservationForm($nestedBuilder, $value, $key); 51 | $builder->add($nestedBuilder); 52 | continue; 53 | } 54 | 55 | $builder->add($key, HiddenType::class, ['data' => urldecode($value)]); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Twig/SortExtension.php: -------------------------------------------------------------------------------- 1 | requestStack->getCurrentRequest(); 38 | $queryParams = $request->query->all($uxTableName); 39 | 40 | $direction = $defaultDirection; 41 | if (($queryParams['sort'][$field] ?? null) === 'desc') { 42 | $direction = null; 43 | } elseif (($queryParams['sort'][$field] ?? null) === 'asc') { 44 | $direction = 'desc'; 45 | } 46 | 47 | if ($multiSort) { 48 | $queryParams['sort'] = []; 49 | } 50 | 51 | $queryParams['sort'][$field] = $direction; 52 | 53 | return $this->urlGenerator->generate( 54 | $request->attributes->get('_route'), 55 | $request->attributes->get('_route_params') + [$uxTableName => $queryParams] + $request->query->all() 56 | ); 57 | } 58 | 59 | public function sortState(string $uxTableName, string $field): string 60 | { 61 | $request = $this->requestStack->getCurrentRequest(); 62 | $queryParams = $request->query->all($uxTableName); 63 | 64 | return $queryParams['sort'][$field] ?? 'none'; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/DataProvider/DoctrineORMProvider.php: -------------------------------------------------------------------------------- 1 | entityManager->createQueryBuilder(); 31 | $qb->select('e'); 32 | $qb->from($class, 'e'); 33 | 34 | foreach ($filters as $field => $value) { 35 | if (is_object($value)) { 36 | $qb->andWhere($qb->expr()->eq('e.' . $field, ':' . $field)); 37 | $qb->setParameter($field, $value); 38 | continue; 39 | } 40 | $qb->andWhere($qb->expr()->like('e.' . $field, ':' . $field)); 41 | $qb->setParameter($field, "%$value%"); 42 | } 43 | 44 | if (is_callable($options['hydrator'] ?? null)) { 45 | $arrayResult = $qb->getQuery()->getResult(AbstractQuery::HYDRATE_ARRAY); 46 | $results = array_map($options['hydrator'], $arrayResult); 47 | } else { 48 | $results = $qb->getQuery(); 49 | } 50 | 51 | return $this->paginator->paginate( 52 | $results, 53 | $page, 54 | $pageSize, 55 | [PaginatorInterface::PAGE_OUT_OF_RANGE => PaginatorInterface::PAGE_OUT_OF_RANGE_FIX] 56 | ); 57 | } 58 | 59 | public function configureOptions(OptionsResolver $resolver): void 60 | { 61 | $resolver 62 | ->define('data_class')->allowedTypes('string')->required() 63 | ->define('hydrator')->allowedTypes('callable') 64 | ; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Twig/OpenerExtension.php: -------------------------------------------------------------------------------- 1 | requestStack->getCurrentRequest(); 41 | 42 | $openerUrl = $request->getRequestUri(); 43 | 44 | return Opener::generate($openerUrl, $source, $this->openerSigner)->toBase64(); 45 | } 46 | 47 | public function retrieveOpenerRaw(): ?string 48 | { 49 | $request = $this->requestStack->getCurrentRequest(); 50 | return $request->query->get('_opener'); 51 | } 52 | 53 | public function retrieveOpenerUrl(): ?string 54 | { 55 | $opener = $this->retrieveOpener(); 56 | 57 | if (!$opener) { 58 | return null; 59 | } 60 | 61 | if (!$opener->isValid($this->openerSigner)) { 62 | return null; 63 | } 64 | 65 | return $opener->getUrl(); 66 | } 67 | 68 | public function retrieveOpenerSource(): ?string 69 | { 70 | return $this->retrieveOpener()?->getSource(); 71 | } 72 | 73 | private function retrieveOpener(): ?Opener 74 | { 75 | $openerRaw = $this->retrieveOpenerRaw(); 76 | 77 | if (!$openerRaw) { 78 | return null; 79 | } 80 | 81 | return Opener::fromBase64($openerRaw); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/DependencyInjection/WeDevelopUXTableExtension.php: -------------------------------------------------------------------------------- 1 | processConfiguration($configuration, $configs); 27 | 28 | $container->register(OpenerSigner::class, OpenerSigner::class) 29 | ->setArguments([ 30 | $config['opener']['secret'] 31 | ]) 32 | ; 33 | 34 | $container->register(OpenerExtension::class, OpenerExtension::class) 35 | ->setAutowired(true) 36 | ->setAutoconfigured(true) 37 | ; 38 | 39 | $container->register(SortExtension::class, SortExtension::class) 40 | ->setAutowired(true) 41 | ->setAutoconfigured(true) 42 | ; 43 | 44 | $container->register(Table::class, Table::class) 45 | ->setAutowired(true) 46 | ->setAutoconfigured(true) 47 | ; 48 | 49 | $container->register(SortLink::class, SortLink::class) 50 | ->setAutowired(true) 51 | ->setAutoconfigured(true) 52 | ; 53 | 54 | $container->register(Filter::class, Filter::class) 55 | ->setAutowired(true) 56 | ->setAutoconfigured(true) 57 | ; 58 | 59 | $container->register(PageSize::class, PageSize::class) 60 | ->setAutowired(true) 61 | ->setAutoconfigured(true) 62 | ; 63 | 64 | $container->registerForAutoconfiguration(DataProviderInterface::class) 65 | ->addTag(DataProviderInterface::class); 66 | 67 | if ($config['use_default_orm_provider']) { 68 | $container->register(DoctrineORMProvider::class) 69 | ->setAutowired(true) 70 | ->setAutoconfigured(true); 71 | } 72 | } 73 | 74 | public function prepend(ContainerBuilder $container) 75 | { 76 | if ($this->isAssetMapperAvailable($container)) { 77 | $container->prependExtensionConfig('framework', [ 78 | 'asset_mapper' => [ 79 | 'paths' => [ 80 | __DIR__.'/../../assets/src' => 'wedevelopnl/ux-table', 81 | ], 82 | ], 83 | ]); 84 | } 85 | } 86 | 87 | private function isAssetMapperAvailable(ContainerBuilder $container): bool 88 | { 89 | if (!interface_exists(AssetMapperInterface::class)) { 90 | return false; 91 | } 92 | 93 | // check that FrameworkBundle 6.3 or higher is installed 94 | $bundlesMetadata = $container->getParameter('kernel.bundles_metadata'); 95 | if (!isset($bundlesMetadata['FrameworkBundle'])) { 96 | return false; 97 | } 98 | 99 | return is_file($bundlesMetadata['FrameworkBundle']['path'].'/Resources/config/asset_mapper.php'); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/Form/PageSizeBuilder.php: -------------------------------------------------------------------------------- 1 | } $options */ 21 | public function __construct( 22 | private readonly array $options = [], 23 | private readonly string $fieldName = self::DEFAULT_FIELD_NAME, 24 | ) {} 25 | 26 | public static function getCalculatedPageSize(FormInterface $form, string $fieldName = self::DEFAULT_FIELD_NAME): int 27 | { 28 | $choiceField = $form->get($fieldName); 29 | if (!is_a($choiceField->getConfig()->getType()->getInnerType(), ChoiceType::class, false)) { 30 | throw new \RuntimeException(sprintf('Form field "%s" must be of type "%s" to calculate page size.', $fieldName, ChoiceType::class)); 31 | } 32 | 33 | // From the query string if it's a valid choice, then the form options 34 | // if it's a valid choice, otherwise the first choice available. 35 | // If there are no valid options to choose from default to the default 36 | // page size defined in this class. 37 | return (int)($choiceField->getNormData() 38 | ?? ($choiceField->getConfig()->getData() ?: null) 39 | ?? ($choiceField->getConfig()->getEmptyData() ?: null) 40 | ?? array_key_first($choiceField->getConfig()->getOption('choices')) 41 | ?? throw new \RuntimeException(sprintf('There must be at least one page size configured for field "%s".', $fieldName))); 42 | } 43 | 44 | public static function addFormOptions(OptionsResolver $resolver): OptionsResolver 45 | { 46 | $resolver->define('pageSize')->allowedTypes('int')->default(PageSizeBuilder::DEFAULT_PAGE_SIZE); 47 | $resolver->define('pageSizes')->allowedTypes('int[]')->default(PageSizeBuilder::DEFAULT_PAGE_SIZES); 48 | 49 | return $resolver; 50 | } 51 | 52 | public function build( 53 | FormBuilderInterface $builder, 54 | array $additionalOptions = [], 55 | ): FormBuilderInterface { 56 | // Default raw value coming from query string (expects to be string). 57 | $default = (string)$this->getDefaultPageSize(); 58 | $builder->add($this->fieldName, ChoiceType::class, array_merge($additionalOptions, [ 59 | 'required' => false, 60 | 'placeholder' => false, 61 | 'choices' => $this->getChoices(), 62 | 'data' => $default, 63 | 'empty_data' => $default, 64 | ])); 65 | 66 | $this->addEventListener($builder); 67 | 68 | return $builder; 69 | } 70 | 71 | /** @return array */ 72 | private function getChoices(): array 73 | { 74 | $choices = array_values($this->validatePageSizes( 75 | $this->options['pageSizes'] ?? null, 76 | ) ?? self::DEFAULT_PAGE_SIZES); 77 | 78 | return array_combine($choices, $choices); 79 | } 80 | 81 | private function getDefaultPageSize(): int 82 | { 83 | $choices = $this->getChoices(); 84 | $defaults = [$this->options['pageSize'] ?? null, self::DEFAULT_PAGE_SIZE, array_key_first($choices)]; 85 | foreach ($defaults as $default) { 86 | if (array_key_exists($default, $choices)) { 87 | return $default; 88 | } 89 | } 90 | 91 | // Unreachable statement (see getChoices/validatePageSizes). Keep PHPStan happy. 92 | return 0; 93 | } 94 | 95 | private function addEventListener(FormBuilderInterface $builder): void 96 | { 97 | $builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event): void { 98 | $data = $event->getData(); 99 | $pageSize = $data[$this->fieldName] ?? null; 100 | if (null !== $pageSize && '' !== $pageSize && !array_key_exists((int) $pageSize, $this->getChoices())) { 101 | $data[$this->fieldName] = $this->getDefaultPageSize(); 102 | $event->setData($data); 103 | } 104 | }); 105 | } 106 | 107 | /** 108 | * @return array|null 109 | */ 110 | private function validatePageSizes(mixed $choices): ?array 111 | { 112 | return is_array($choices) && count($choices) > 0 && array_reduce( 113 | $choices, 114 | fn($carry, $item) => $carry && is_int($item), 115 | true, 116 | ) ? $choices : null; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/Table/AbstractTable.php: -------------------------------------------------------------------------------- 1 | dataProviders->get($this->getDataProvider()); 41 | 42 | $options = $this->resolveOptions($options, $dataProvider); 43 | 44 | // Make sure default values are set even if form isn't submitted 45 | if (!$request->query->has($this->getName())) { 46 | $request->query->set($this->getName(), null); 47 | } 48 | 49 | $this->form = $this->buildForm($options); 50 | $this->form->handleRequest($request); 51 | 52 | $this->pagination = $dataProvider->search( 53 | filters: $additionalFilters + $this->getFilters(), 54 | sort: $this->getSort($request, $options['sortable_fields']), 55 | page: $this->getPage($request), 56 | pageSize: $this->getPageSize(), 57 | options: $options, 58 | ); 59 | 60 | $this->pagination->setPaginatorOptions([PaginatorInterface::PAGE_PARAMETER_NAME => sprintf('%s[page]', $this->getName())]); 61 | } 62 | 63 | public function getForm(): FormInterface 64 | { 65 | if (!isset($this->form)) { 66 | throw new \LogicException(sprintf('Execute %s::process() before retrieving the form', $this::class)); 67 | } 68 | 69 | return $this->form; 70 | } 71 | 72 | public function getFormView(): FormView 73 | { 74 | if (!isset($this->formView)) { 75 | $this->formView = $this->getForm()->createView(); 76 | } 77 | 78 | return $this->formView; 79 | } 80 | 81 | public function getResults(): PaginationInterface 82 | { 83 | if (!isset($this->pagination)) { 84 | throw new \LogicException('First execute the ->process() method to generate results'); 85 | } 86 | 87 | return $this->pagination; 88 | } 89 | 90 | abstract public function getName(): string; 91 | 92 | abstract protected function buildFilterForm(FormBuilderInterface $builder, array $options): void; 93 | 94 | protected function getDataProvider(): string 95 | { 96 | return DoctrineORMProvider::class; 97 | } 98 | 99 | protected function configureOptions(OptionsResolver $resolver): void 100 | { 101 | } 102 | 103 | protected function stimulusSearch(?string $event = 'input'): string 104 | { 105 | return $event . '->wedevelopnl--ux-table--ux-table#search'; 106 | } 107 | 108 | protected function stimulusSearchAttributes(?string $event = 'input'): array 109 | { 110 | return [ 111 | 'data-action' => $this->stimulusSearch($event), 112 | 'data-turbo-permanent' => 'true', 113 | ]; 114 | } 115 | 116 | /** @param array{pageSize?: int, pageSizes?: array} $options */ 117 | private function buildForm(array $options): FormInterface 118 | { 119 | $builder = $this->buildBaseForm(); 120 | 121 | $this->addPageSize($builder, $options); 122 | 123 | $filterFormBuilder = $builder->create('filter', FormType::class, ['label' => false]); 124 | 125 | $this->buildFilterForm($filterFormBuilder, $options); 126 | $builder->add($filterFormBuilder); 127 | 128 | return $builder->getForm(); 129 | } 130 | 131 | private function buildBaseForm(): FormBuilderInterface 132 | { 133 | $builder = $this->formFactory->createNamedBuilder($this->getName()); 134 | 135 | $builder 136 | ->setMethod('GET') 137 | ->addEventListener(FormEvents::SUBMIT, function (FormEvent $event) { 138 | $data = $event->getData(); 139 | $filter = array_filter($data['filter'] ?? [], fn($v) => $v !== null); 140 | $data['filter'] = $filter; 141 | $event->setData($data); 142 | }); 143 | 144 | return $builder; 145 | } 146 | 147 | /** @param array{pageSize?: int, pageSizes?: array} $options */ 148 | private function addPageSize(FormBuilderInterface $builder, array $options): void 149 | { 150 | $pageSizeBuilder = new PageSizeBuilder($options); 151 | $pageSizeBuilder->build($builder, [ 152 | 'attr' => ['data-action' => $this->stimulusSearch('change')], 153 | ]); 154 | } 155 | 156 | private function getFilters(): array 157 | { 158 | return $this->getForm()->getData()['filter'] ?? []; 159 | } 160 | 161 | private function getSort(Request $request, array $sortableFields): array 162 | { 163 | $sort = $request->query->all($this->getName())['sort'] ?? []; 164 | 165 | return array_filter($sort, fn($key) => in_array($key, $sortableFields), ARRAY_FILTER_USE_KEY); 166 | } 167 | 168 | private function getPage(Request $request): int 169 | { 170 | return (int)($request->query->all($this->getName())['page'] ?? 1); 171 | } 172 | 173 | private function getPageSize(): int 174 | { 175 | return PageSizeBuilder::getCalculatedPageSize($this->getForm()); 176 | } 177 | 178 | private function resolveOptions(array $options, DataProviderInterface $dataProvider): array 179 | { 180 | $resolver = new OptionsResolver(); 181 | $resolver 182 | ->define('sortable_fields')->allowedTypes('array')->default([])->required() 183 | ; 184 | 185 | $dataProvider->configureOptions($resolver); 186 | PageSizeBuilder::addFormOptions($resolver); 187 | $this->configureOptions($resolver); 188 | 189 | return $resolver->resolve($options); 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WeDevelop UX Table 2 | 3 | **EXPERIMENTAL**: This package is still under development, everything is subject to change. 4 | 5 | ## Description 6 | 7 | UX Table is a Data Table designed to work well 8 | with [Symfony UX Turbo](https://symfony.com/bundles/ux-turbo/current/index.html) 9 | and [Stimulus](https://stimulus.hotwired.dev/) (part of Symfony Encore). 10 | This package aims to utilize the beautiful simplicity 11 | of [Hypermedia-Driven Application](https://htmx.org/essays/hypermedia-driven-applications/) (HDA) architecture. 12 | 13 | This package contains building blocks to create a completely flexible data table. 14 | More specifically, it helps generate forms (with hidden inputs) and links that retain the state of your UX table. 15 | 16 | The pros of this method: 17 | 18 | - Full control over your template 19 | - No javascript (even works with javascript disabled) 20 | - No serialization 21 | 22 | ## Prerequisites 23 | 24 | ### Symfony UX Turbo 25 | 26 | Make sure Symfony UX Turbo is installed and setup. We heavily rely on this functionality to make this a nice user experience. 27 | 28 | ## Install 29 | 30 | ```sh 31 | composer require wedevelopnl/ux-table 32 | ``` 33 | 34 | ## With Symfony Flex 35 | 36 | You should be able to just get started. 37 | 38 | ## Without Symfony Flex 39 | 40 | 1. `npm i -D vendor/wedevelopnl/ux-table/assets` 41 | 2. Add to `bundles.php` 42 | ```php 43 | WeDevelop\UXTable\WeDevelopUXTableBundle::class => ['all' => true], 44 | ``` 45 | 3. Add to `assets/controllers.json` 46 | ```json 47 | { 48 | "controllers": { 49 | "@wedevelopnl/ux-table": { 50 | "ux-table": { 51 | "enabled": true, 52 | "fetch": "eager" 53 | } 54 | } 55 | }, 56 | "entrypoints": [] 57 | } 58 | ``` 59 | 60 | ## Getting started 61 | 62 | ### Create a form 63 | 64 | First we create a new Form which extends `WeDevelop\UXTable\Table\AbstractTable` 65 | 66 | ```php 67 | add('name', SearchType::class, [ 87 | 'attr' => $this->stimulusSearchAttributes(), 88 | 'required' => false, 89 | ]) 90 | ; 91 | } 92 | 93 | protected function configureOptions(OptionsResolver $resolver): void 94 | { 95 | parent::configureOptions($resolver); 96 | 97 | $resolver->setDefaults([ 98 | 'data_class' => \App\Entity\Project::class, 99 | 'sortable_fields' => ['name'], 100 | ]); 101 | } 102 | } 103 | 104 | ``` 105 | 106 | This is a basic data table which adds a filter for the `name` field. 107 | 108 | ### Create a controller action 109 | 110 | ```php 111 | #[Route('/projects', name: 'app_project_list')] 112 | public function listAction(Request $request, ProjectsTable $projectsTable): Response 113 | { 114 | $projectsTable->process($request); 115 | 116 | return $this->render( 117 | 'project/list.html.twig', 118 | ['projectsTable' => $projectsTable] 119 | ); 120 | } 121 | ``` 122 | 123 | ### Template 124 | 125 | ```twig 126 | {# Optional optimization #} 127 | {% extends app.request.headers.has('turbo-frame') ? 'empty.html.twig' : 'page.html.twig' %} 128 | 129 | {% block main %} 130 |

Projects

131 | 132 | 133 | 134 | 135 | 136 | 140 | 141 | 142 | 143 | 144 | {% for project in projectsTable.results %} 145 | 146 | 147 | 149 | 150 | {% endfor %} 151 | 152 |
137 | 138 | 139 |
{{ project.name }}View 148 |
153 | 154 | Page size 155 | {{ form_widget(projectsTable.formView.pageSize) }} 156 | 157 | Pagination 158 | {{ knp_pagination_render(projectsTable.results) }} 159 |
160 | {% endblock %} 161 | ``` 162 | 163 | There's a few important things here. 164 | 165 | ```twig 166 | {% extends app.request.headers.has('turbo-frame') ? 'empty.html.twig' : 'page.html.twig' %} 167 | ``` 168 | 169 | This makes it so that when we're navigating within a Turbo Frame, we make sure not to render the entire layout, for 170 | performance's sake. 171 | 172 | ```twig 173 | 174 | ``` 175 | 176 | We use the UX Table Twig Component , it's a very slim component that makes sure everything is wrapped in a 177 | stimulus controller, turbo frame and form tags 178 | 179 | ```twig 180 | 181 | ``` 182 | 183 | Here we utilize the SortLink Twig Component to generate a link that retains the query parameters that contain the state 184 | of the UX Table. 185 | 186 | ```twig 187 | 188 | ``` 189 | 190 | Here we utilize the Filter Twig Component to show the form field for that filter. 191 | 192 | ### Data Providers 193 | 194 | By default, this package relies on the DoctrineORMProvider provided to automatically query the database. 195 | 196 | If you want to use custom hydration you can configure a hydrator for the DoctrineORMProvider: 197 | 198 | ```php 199 | protected function configureOptions(OptionsResolver $resolver): void 200 | { 201 | parent::configureOptions($resolver); 202 | 203 | $resolver->setDefaults([ 204 | 'data_class' => \App\Entity\Project::class, 205 | 'hydrator' => function (array $project) { 206 | return new \App\ReadModel\Project($project['id'], $project['name']); 207 | }, 208 | 'sortable_fields' => ['name'], 209 | ]); 210 | } 211 | ``` 212 | 213 | You can also create your own DataProvider by creating a class that implements 214 | the `WeDevelop\UXTable\DataProvider\DataProviderInterface`. 215 | 216 | ```php 217 | final class ProjectsProvider implements DataProviderInterface 218 | { 219 | public function __construct( 220 | private readonly ApiClient $api, 221 | private readonly PaginatorInterface $paginator, 222 | ) { 223 | } 224 | 225 | public function search( 226 | array $filters = [], 227 | array $sort = [], 228 | int $page = 1, 229 | int $pageSize = 50, 230 | array $options = [] 231 | ): PaginationInterface { 232 | $status = $options['status']; 233 | 234 | $projects = $this->api->getProjects($status, $filters, $sort, $page, $pageSize); 235 | 236 | return $this->paginator->paginate($projects, $page, $pageSize, [PaginatorInterface::PAGE_OUT_OF_RANGE => PaginatorInterface::PAGE_OUT_OF_RANGE_THROW_EXCEPTION]); 237 | } 238 | 239 | public function configureOptions(OptionsResolver $resolver): void 240 | { 241 | $resolver 242 | ->define('status')->allowedTypes(ProjectStatus::class)->required() 243 | ; 244 | } 245 | } 246 | ``` 247 | 248 | Here we also define a status option which can be passes to the process function: 249 | 250 | ```php 251 | $projectsTable->process($request, options: ['status' => 'active']); 252 | ``` 253 | --------------------------------------------------------------------------------