├── .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 | |
137 |
138 |
139 | |
140 | |
141 |
142 |
143 |
144 | {% for project in projectsTable.results %}
145 |
146 | | {{ project.name }} |
147 | View
148 | |
149 |
150 | {% endfor %}
151 |
152 |
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 |
--------------------------------------------------------------------------------