Skip to content

Commit 49273ce

Browse files
authored
Merge branch '2.x' into refactor-ajax-calls
2 parents cdaa112 + 75272b1 commit 49273ce

File tree

22 files changed

+271
-45
lines changed

22 files changed

+271
-45
lines changed

.github/workflows/test.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ jobs:
1010
- uses: shivammathur/setup-php@v2
1111
with:
1212
php-version: '8.0'
13-
tools: php-cs-fixer, cs2pr
13+
tools: php-cs-fixer
1414
- name: php-cs-fixer
15-
run: php-cs-fixer fix --dry-run --format=checkstyle | cs2pr
15+
run: php-cs-fixer fix --dry-run --diff
1616

1717
coding-style-js:
1818
runs-on: ubuntu-latest

src/Autocomplete/src/Form/AutocompleteChoiceTypeExtension.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@ public function finishView(FormView $view, FormInterface $form, array $options)
6767
$values['tom-select-options'] = json_encode($options['tom_select_options']);
6868
}
6969

70+
if ($options['max_results']) {
71+
$values['max-results'] = $options['max_results'];
72+
}
73+
7074
$values['no-results-found-text'] = $this->trans($options['no_results_found_text']);
7175
$values['no-more-results-text'] = $this->trans($options['no_more_results_text']);
7276

@@ -87,6 +91,7 @@ public function configureOptions(OptionsResolver $resolver)
8791
'allow_options_create' => false,
8892
'no_results_found_text' => 'No results found',
8993
'no_more_results_text' => 'No more results',
94+
'max_results' => 10,
9095
]);
9196

9297
// if autocomplete_url is passed, then HTML options are already supported

src/Autocomplete/src/Form/AutocompleteEntityTypeSubscriber.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ public function preSetData(FormEvent $event)
3939
// pass to AutocompleteChoiceTypeExtension
4040
$options['autocomplete'] = true;
4141
$options['autocomplete_url'] = $this->autocompleteUrl;
42-
unset($options['searchable_fields'], $options['security'], $options['filter_query']);
42+
unset($options['searchable_fields'], $options['security'], $options['filter_query'], $options['max_results']);
4343

4444
$form->add('autocomplete', EntityType::class, $options);
4545
}

src/Autocomplete/src/Form/ParentEntityAutocompleteType.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,13 @@ public function configureOptions(OptionsResolver $resolver)
7676
// set to the string role that's required to view the autocomplete results
7777
// or a callable: function(Symfony\Component\Security\Core\Security $security): bool
7878
'security' => false,
79+
// set the max results number that a query on automatic endpoint return.
80+
'max_results' => 10,
7981
]);
8082

8183
$resolver->setRequired(['class']);
8284
$resolver->setAllowedTypes('security', ['boolean', 'string', 'callable']);
85+
$resolver->setAllowedTypes('max_results', ['int', 'null']);
8386
$resolver->setAllowedTypes('filter_query', ['callable', 'null']);
8487
$resolver->setNormalizer('searchable_fields', function (Options $options, ?array $searchableFields) {
8588
if (null !== $searchableFields && null !== $options['filter_query']) {

src/Autocomplete/src/Form/WrappedEntityTypeAutocompleter.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ public function createFilteredQueryBuilder(EntityRepository $repository, string
5454
return $queryBuilder;
5555
}
5656

57+
// Applying max result limit or not
58+
$queryBuilder->setMaxResults($this->getMaxResults());
59+
5760
$this->entitySearchUtil->addSearchClause(
5861
$queryBuilder,
5962
$query,
@@ -131,6 +134,11 @@ private function getFilterQuery(): ?callable
131134
return $this->getForm()->getConfig()->getOption('filter_query');
132135
}
133136

137+
private function getMaxResults(): ?int
138+
{
139+
return $this->getForm()->getConfig()->getOption('max_results');
140+
}
141+
134142
private function getEntityMetadata(): EntityMetadata
135143
{
136144
if (null === $this->entityMetadata) {

src/Autocomplete/src/Resources/doc/index.rst

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -156,9 +156,11 @@ After creating this class, use it in your form:
156156
}
157157
}
158158
159-
For consistent results, avoid passing any options to the 3rd argument
160-
of the ``->add()`` method. Instead, include all options inside the
161-
custom class (``FoodAutocompleteField``).
159+
.. caution::
160+
161+
Avoid passing any options to the 3rd argument of the ``->add()`` method as
162+
these won't be used during the Ajax call to fetch results. Instead, include
163+
all options inside the custom class (``FoodAutocompleteField``).
162164

163165
Congratulations! Your ``EntityType`` is now Ajax-powered!
164166

@@ -258,6 +260,8 @@ to the options above, you can also pass:
258260
$qb->andWhere('entity.name LIKE :filter OR entity.description LIKE :filter')
259261
->setParameter('filter', '%'.$query.'%');
260262
}
263+
``max_results`` (default: 10)
264+
Allow you to control the max number of results returned by the automatic autocomplete endpoint.
261265

262266
Using with a TextType Field
263267
---------------------------

src/Autocomplete/tests/Fixtures/Form/CategoryAutocompleteType.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ public function configureOptions(OptionsResolver $resolver)
4141
'attr' => [
4242
'data-controller' => 'custom-autocomplete',
4343
],
44+
'max_results' => 5,
4445
]);
4546
}
4647

src/Autocomplete/tests/Functional/FieldAutocompleterTest.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,4 +78,16 @@ public function testItEnforcesSecurity(): void
7878
->assertJsonMatches('length(results)', 3)
7979
;
8080
}
81+
82+
public function testItCheckMaxResultsOption(): void
83+
{
84+
CategoryFactory::createMany(30, ['name' => 'foo']);
85+
86+
$this->browser()
87+
->throwExceptions()
88+
->get('/test/autocomplete/category_autocomplete_type?query=foo')
89+
->assertSuccessful()
90+
->assertJsonMatches('length(results)', 5)
91+
;
92+
}
8193
}

src/LiveComponent/assets/dist/live_controller.js

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1434,18 +1434,21 @@ class default_1 extends Controller {
14341434
const thisPromise = fetch(`${url}${paramsString.length > 0 ? `?${paramsString}` : ''}`, fetchOptions);
14351435
const reRenderPromise = new ReRenderPromise(thisPromise, this.unsyncedInputs.clone());
14361436
this.renderPromiseStack.addPromise(reRenderPromise);
1437-
thisPromise.then((response) => {
1437+
thisPromise.then(async (response) => {
14381438
if (action) {
14391439
this.isActionProcessing = false;
14401440
}
1441+
const html = await response.text();
1442+
if (response.headers.get('Content-Type') !== 'application/vnd.live-component+html') {
1443+
this.renderError(html);
1444+
return;
1445+
}
14411446
if (this.renderDebounceTimeout) {
14421447
return;
14431448
}
14441449
const isMostRecent = this.renderPromiseStack.removePromise(thisPromise);
14451450
if (isMostRecent) {
1446-
response.text().then((html) => {
1447-
this._processRerender(html, response, reRenderPromise.unsyncedInputContainer);
1448-
});
1451+
this._processRerender(html, response, reRenderPromise.unsyncedInputContainer);
14491452
}
14501453
});
14511454
}
@@ -1832,6 +1835,48 @@ class default_1 extends Controller {
18321835
clearInterval(interval);
18331836
});
18341837
}
1838+
async renderError(html) {
1839+
let modal = document.getElementById('live-component-error');
1840+
if (modal) {
1841+
modal.innerHTML = '';
1842+
}
1843+
else {
1844+
modal = document.createElement('div');
1845+
modal.id = 'live-component-error';
1846+
modal.style.padding = '50px';
1847+
modal.style.backgroundColor = 'rgba(0, 0, 0, .5)';
1848+
modal.style.zIndex = '100000';
1849+
modal.style.position = 'fixed';
1850+
modal.style.width = '100vw';
1851+
modal.style.height = '100vh';
1852+
}
1853+
const iframe = document.createElement('iframe');
1854+
iframe.style.borderRadius = '5px';
1855+
iframe.style.width = '100%';
1856+
iframe.style.height = '100%';
1857+
modal.appendChild(iframe);
1858+
document.body.prepend(modal);
1859+
document.body.style.overflow = 'hidden';
1860+
if (iframe.contentWindow) {
1861+
iframe.contentWindow.document.open();
1862+
iframe.contentWindow.document.write(html);
1863+
iframe.contentWindow.document.close();
1864+
}
1865+
const closeModal = (modal) => {
1866+
if (modal) {
1867+
modal.outerHTML = '';
1868+
}
1869+
document.body.style.overflow = 'visible';
1870+
};
1871+
modal.addEventListener('click', () => closeModal(modal));
1872+
modal.setAttribute('tabindex', '0');
1873+
modal.addEventListener('keydown', e => {
1874+
if (e.key === 'Escape') {
1875+
closeModal(modal);
1876+
}
1877+
});
1878+
modal.focus();
1879+
}
18351880
}
18361881
default_1.values = {
18371882
url: String,

src/LiveComponent/assets/src/live_controller.ts

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -458,13 +458,17 @@ export default class extends Controller implements LiveController {
458458
const paramsString = params.toString();
459459
const thisPromise = fetch(`${url}${paramsString.length > 0 ? `?${paramsString}` : ''}`, fetchOptions);
460460
this.backendRequest = new BackendRequest(thisPromise);
461-
thisPromise.then((response) => {
462-
response.text().then((html) => {
463-
this.#processRerender(html, response);
461+
thisPromise.then(async (response) => {
462+
const html = await response.text();
463+
if (response.headers.get('Content-Type') !== 'application/vnd.live-component+html') {
464+
this.renderError(html);
465+
return;
466+
}
464467

465-
this.backendRequest = null;
466-
this.#startPendingRequest();
467-
});
468+
this.#processRerender(html, response);
469+
470+
this.backendRequest = null;
471+
this.#startPendingRequest();
468472
})
469473
}
470474

@@ -1018,6 +1022,56 @@ export default class extends Controller implements LiveController {
10181022
});
10191023
}
10201024

1025+
// inspired by Livewire!
1026+
private async renderError(html: string) {
1027+
let modal = document.getElementById('live-component-error');
1028+
if (modal) {
1029+
modal.innerHTML = '';
1030+
} else {
1031+
modal = document.createElement('div');
1032+
modal.id = 'live-component-error';
1033+
modal.style.padding = '50px';
1034+
modal.style.backgroundColor = 'rgba(0, 0, 0, .5)';
1035+
modal.style.zIndex = '100000';
1036+
modal.style.position = 'fixed';
1037+
modal.style.width = '100vw';
1038+
modal.style.height = '100vh';
1039+
}
1040+
1041+
const iframe = document.createElement('iframe');
1042+
iframe.style.borderRadius = '5px';
1043+
iframe.style.width = '100%';
1044+
iframe.style.height = '100%';
1045+
modal.appendChild(iframe);
1046+
1047+
document.body.prepend(modal);
1048+
document.body.style.overflow = 'hidden';
1049+
if (iframe.contentWindow) {
1050+
iframe.contentWindow.document.open();
1051+
iframe.contentWindow.document.write(html);
1052+
iframe.contentWindow.document.close();
1053+
}
1054+
1055+
const closeModal = (modal: HTMLElement|null) => {
1056+
if (modal) {
1057+
modal.outerHTML = ''
1058+
}
1059+
document.body.style.overflow = 'visible'
1060+
}
1061+
1062+
// close on click
1063+
modal.addEventListener('click', () => closeModal(modal));
1064+
1065+
// close on escape
1066+
modal.setAttribute('tabindex', '0');
1067+
modal.addEventListener('keydown', e => {
1068+
if (e.key === 'Escape') {
1069+
closeModal(modal);
1070+
}
1071+
});
1072+
modal.focus();
1073+
}
1074+
10211075
#clearRequestDebounceTimeout() {
10221076
// clear any pending renders
10231077
if (this.requestDebounceTimeout) {
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/*
2+
* This file is part of the Symfony package.
3+
*
4+
* (c) Fabien Potencier <fabien@symfony.com>
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+
'use strict';
11+
12+
import { createTest, initComponent, shutdownTest } from '../tools';
13+
import { getByText, waitFor } from '@testing-library/dom';
14+
15+
describe('LiveController Error Handling', () => {
16+
afterEach(() => {
17+
shutdownTest();
18+
})
19+
20+
it('displays an error modal on 500 errors', async () => {
21+
const test = await createTest({ }, (data: any) => `
22+
<div ${initComponent(data)}>
23+
Original component text
24+
<button data-action="live#action" data-action-name="save">Save</button>
25+
</div>
26+
`);
27+
28+
// ONLY a post is sent, not a re-render GET
29+
test.expectsAjaxCall('post')
30+
.expectSentData(test.initialData)
31+
.serverWillReturnCustomResponse(500, `
32+
<html><head><title>Error!</title></head><body><h1>An error occurred</h1></body></html>
33+
`)
34+
.expectActionCalled('save')
35+
.init();
36+
37+
getByText(test.element, 'Save').click();
38+
39+
await waitFor(() => expect(document.getElementById('live-component-error')).not.toBeNull());
40+
// the component did not change or re-render
41+
expect(test.element).toHaveTextContent('Original component text');
42+
const errorContainer = document.getElementById('live-component-error');
43+
if (!errorContainer) {
44+
throw new Error('containing missing');
45+
}
46+
expect(errorContainer.querySelector('iframe')).not.toBeNull();
47+
});
48+
49+
it('displays a modal on any non-component response', async () => {
50+
const test = await createTest({ }, (data: any) => `
51+
<div ${initComponent(data)}>
52+
Original component text
53+
<button data-action="live#action" data-action-name="save">Save</button>
54+
</div>
55+
`);
56+
57+
// ONLY a post is sent, not a re-render GET
58+
test.expectsAjaxCall('post')
59+
.expectSentData(test.initialData)
60+
.serverWillReturnCustomResponse(200, `
61+
<html><head><title>Hi!</title></head><body><h1>I'm a whole page, not a component!</h1></body></html>
62+
`)
63+
.expectActionCalled('save')
64+
.init();
65+
66+
getByText(test.element, 'Save').click();
67+
68+
await waitFor(() => expect(document.getElementById('live-component-error')).not.toBeNull());
69+
// the component did not change or re-render
70+
expect(test.element).toHaveTextContent('Original component text');
71+
});
72+
});

0 commit comments

Comments
 (0)