diff --git a/.github/workflows/test-turbo.yml b/.github/workflows/test-turbo.yml index 23e2d229c6d..530b17b548e 100644 --- a/.github/workflows/test-turbo.yml +++ b/.github/workflows/test-turbo.yml @@ -17,20 +17,9 @@ jobs: php-version: '8.0' extensions: zip - - name: Get composer cache directory - id: composercache - run: echo "::set-output name=dir::$(composer config cache-files-dir)" - - - name: Cache dependencies - uses: actions/cache@v2 + - uses: ramsey/composer-install@v2 with: - path: ${{ steps.composercache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} - restore-keys: ${{ runner.os }}-composer- - - - name: Install dependencies - working-directory: src/Turbo - run: composer install --prefer-dist + working-directory: src/Turbo - name: Install PHPUnit dependencies working-directory: src/Turbo @@ -71,20 +60,9 @@ jobs: php-version: ${{ matrix.php-versions }} extensions: zip, pdo_sqlite - - name: Get composer cache directory - id: composercache - run: echo "::set-output name=dir::$(composer config cache-files-dir)" - - - name: Cache PHP dependencies - uses: actions/cache@v2 + - uses: ramsey/composer-install@v2 with: - path: ${{ steps.composercache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} - restore-keys: ${{ runner.os }}-composer- - - - name: Install PHP dependencies - working-directory: src/Turbo - run: composer install --prefer-dist + working-directory: src/Turbo - name: Get yarn cache directory path id: yarn-cache-dir-path @@ -143,20 +121,10 @@ jobs: php-version: '8.0' extensions: zip, pdo_sqlite - - name: Get composer cache directory - id: composercache - run: echo "::set-output name=dir::$(composer config cache-files-dir)" - - - name: Cache PHP dependencies - uses: actions/cache@v2 + - uses: ramsey/composer-install@v2 with: - path: ${{ steps.composercache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} - restore-keys: ${{ runner.os }}-composer- - - - name: Install PHP dependencies - working-directory: src/Turbo - run: composer update --prefer-dist --prefer-lowest --prefer-stable + working-directory: src/Turbo + dependency-versions: lowest - name: Get yarn cache directory path id: yarn-cache-dir-path diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index f7d65303f17..18459dc1716 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -18,10 +18,45 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@master + - name: Get yarn cache directory path + id: yarn-cache-dir-path + run: echo "::set-output name=dir::$(yarn cache dir)" + - uses: actions/cache@v2 + id: yarn-cache + with: + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/package.json') }} + restore-keys: | + ${{ runner.os }}-yarn- - run: yarn - run: yarn check-lint - run: yarn check-format + js-dist-current: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@master + - name: Get yarn cache directory path + id: yarn-cache-dir-path + run: echo "::set-output name=dir::$(yarn cache dir)" + - uses: actions/cache@v2 + id: yarn-cache + with: + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/package.json') }} + restore-keys: | + ${{ runner.os }}-yarn- + - run: yarn && yarn build + - name: Check if js dist files are current + id: changes + uses: UnicornGlobal/has-changes-action@v1.0.11 + + - name: Ensure no changes + if: steps.changes.outputs.changed == 1 + run: | + echo "JS dist files need to be rebuilt" + exit 1 + tests-php-low-deps: runs-on: ubuntu-latest steps: @@ -29,26 +64,42 @@ jobs: - uses: shivammathur/setup-php@v2 with: php-version: '7.2' - - name: Chartjs - run: | - cd src/Chartjs - composer update --prefer-lowest --prefer-dist --no-interaction --no-ansi --no-progress - php vendor/bin/simple-phpunit - - name: Cropperjs - run: | - cd src/Cropperjs - composer update --prefer-lowest --prefer-dist --no-interaction --no-ansi --no-progress - php vendor/bin/simple-phpunit - - name: Dropzone - run: | - cd src/Dropzone - composer update --prefer-lowest --prefer-dist --no-interaction --no-ansi --no-progress - php vendor/bin/simple-phpunit - - name: LazyImage - run: | - cd src/LazyImage - composer update --prefer-lowest --prefer-dist --no-interaction --no-ansi --no-progress - php vendor/bin/simple-phpunit + + - name: Chartjs Dependencies + uses: ramsey/composer-install@v2 + with: + working-directory: src/Chartjs + dependency-versions: lowest + - name: Chartjs Tests + run: php vendor/bin/simple-phpunit + working-directory: src/Chartjs + + - name: Cropperjs Dependencies + uses: ramsey/composer-install@v2 + with: + working-directory: src/Cropperjs + dependency-versions: lowest + - name: Cropperjs Tests + run: php vendor/bin/simple-phpunit + working-directory: src/Cropperjs + + - name: Dropzone Dependencies + uses: ramsey/composer-install@v2 + with: + working-directory: src/Dropzone + dependency-versions: lowest + - name: Dropzone Tests + run: php vendor/bin/simple-phpunit + working-directory: src/Dropzone + + - name: LazyImage Dependencies + uses: ramsey/composer-install@v2 + with: + working-directory: src/LazyImage + dependency-versions: lowest + - name: LazyImage Tests + run: php vendor/bin/simple-phpunit + working-directory: src/LazyImage tests-php8-low-deps: runs-on: ubuntu-latest @@ -57,17 +108,25 @@ jobs: - uses: shivammathur/setup-php@v2 with: php-version: '8.0' - - name: TwigComponent - run: | - cd src/TwigComponent - composer update --prefer-lowest --prefer-dist --no-interaction --no-ansi --no-progress - php vendor/bin/simple-phpunit - - name: LiveComponent - run: | - cd src/LiveComponent - php ../../.github/build-packages.php - composer update --prefer-lowest --prefer-dist --no-interaction --no-ansi --no-progress - php vendor/bin/simple-phpunit + - run: php .github/build-packages.php + + - name: TwigComponent Dependencies + uses: ramsey/composer-install@v2 + with: + working-directory: src/TwigComponent + dependency-versions: lowest + - name: TwigComponent Tests + run: php vendor/bin/simple-phpunit + working-directory: src/TwigComponent + + - name: LiveComponent Dependencies + uses: ramsey/composer-install@v2 + with: + working-directory: src/LiveComponent + dependency-versions: lowest + - name: LiveComponent Tests + working-directory: src/LiveComponent + run: php vendor/bin/simple-phpunit tests-php-high-deps: runs-on: ubuntu-latest @@ -76,41 +135,69 @@ jobs: - uses: shivammathur/setup-php@v2 with: php-version: '8.0' - - name: Chartjs - run: | - cd src/Chartjs - composer update --prefer-dist --no-interaction --no-ansi --no-progress - php vendor/bin/simple-phpunit - - name: Cropperjs - run: | - cd src/Cropperjs - composer update --prefer-dist --no-interaction --no-ansi --no-progress - php vendor/bin/simple-phpunit - - name: Dropzone - run: | - cd src/Dropzone - composer update --prefer-dist --no-interaction --no-ansi --no-progress - php vendor/bin/simple-phpunit - - name: LazyImage - run: | - cd src/LazyImage - composer update --prefer-dist --no-interaction --no-ansi --no-progress - php vendor/bin/simple-phpunit - - name: TwigComponent - run: | - cd src/TwigComponent - composer update --prefer-dist --no-interaction --no-ansi --no-progress - php vendor/bin/simple-phpunit - - name: LiveComponent - run: | - cd src/LiveComponent - php ../../.github/build-packages.php - composer update --prefer-dist --no-interaction --no-ansi --no-progress - php vendor/bin/simple-phpunit + - run: php .github/build-packages.php + + - name: Chartjs Dependencies + uses: ramsey/composer-install@v2 + with: + working-directory: src/Chartjs + - name: Chartjs Tests + run: php vendor/bin/simple-phpunit + working-directory: src/Chartjs + + - name: Cropperjs Dependencies + uses: ramsey/composer-install@v2 + with: + working-directory: src/Cropperjs + - name: Cropperjs Tests + run: php vendor/bin/simple-phpunit + working-directory: src/Cropperjs + + - name: Dropzone Dependencies + uses: ramsey/composer-install@v2 + with: + working-directory: src/Dropzone + - name: Dropzone Tests + run: php vendor/bin/simple-phpunit + working-directory: src/Dropzone + + - name: LazyImage Dependencies + uses: ramsey/composer-install@v2 + with: + working-directory: src/LazyImage + - name: LazyImage Tests + run: php vendor/bin/simple-phpunit + working-directory: src/LazyImage + + - name: TwigComponent Dependencies + uses: ramsey/composer-install@v2 + with: + working-directory: src/TwigComponent + - name: TwigComponent Tests + run: php vendor/bin/simple-phpunit + working-directory: src/TwigComponent + + - name: LiveComponent Dependencies + uses: ramsey/composer-install@v2 + with: + working-directory: src/LiveComponent + - name: LiveComponent Tests + working-directory: src/LiveComponent + run: php vendor/bin/simple-phpunit tests-js: runs-on: ubuntu-latest steps: - uses: actions/checkout@master + - name: Get yarn cache directory path + id: yarn-cache-dir-path + run: echo "::set-output name=dir::$(yarn cache dir)" + - uses: actions/cache@v2 + id: yarn-cache + with: + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: ${{ runner.os }}-yarn-${{ hashFiles('**/package.json') }} + restore-keys: | + ${{ runner.os }}-yarn- - run: yarn - run: yarn test diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index a14f813bcd1..84162cee094 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -24,6 +24,7 @@ ->append([__FILE__]) ->notPath('#/Fixtures/#') ->notPath('#/app/var/#') + ->notPath('#/var/cache/#') ->notPath('Turbo/Attribute/Broadcast.php') // Need https://github.com/FriendsOfPHP/PHP-CS-Fixer/issues/4702 ) ; diff --git a/package.json b/package.json index a3c45faaea8..4f79006e47b 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,8 @@ "plugin:@typescript-eslint/recommended" ], "rules": { - "@typescript-eslint/no-explicit-any": "off" + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-empty-function": "off" }, "env": { "browser": true diff --git a/src/Dropzone/Resources/assets/dist/controller.js b/src/Dropzone/Resources/assets/dist/controller.js index e31134956e4..126e3cd5c7d 100644 --- a/src/Dropzone/Resources/assets/dist/controller.js +++ b/src/Dropzone/Resources/assets/dist/controller.js @@ -43,7 +43,7 @@ class default_1 extends Controller { }); reader.readAsDataURL(file); } - _dispatchEvent(name, payload) { + _dispatchEvent(name, payload = {}) { this.element.dispatchEvent(new CustomEvent(name, { detail: payload })); } } diff --git a/src/LazyImage/Resources/assets/dist/controller.js b/src/LazyImage/Resources/assets/dist/controller.js index 3dda83c1d1d..4cbb82c3d6b 100644 --- a/src/LazyImage/Resources/assets/dist/controller.js +++ b/src/LazyImage/Resources/assets/dist/controller.js @@ -3,11 +3,12 @@ import { Controller } from '@hotwired/stimulus'; class default_1 extends Controller { connect() { const hd = new Image(); + const element = this.element; const srcsetString = this._calculateSrcsetString(); hd.addEventListener('load', () => { - this.element.src = this.srcValue; + element.src = this.srcValue; if (srcsetString) { - this.element.srcset = srcsetString; + element.srcset = srcsetString; } this._dispatchEvent('lazy-image:ready', { image: hd }); }); diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md index cb13a102727..810078b8203 100644 --- a/src/LiveComponent/CHANGELOG.md +++ b/src/LiveComponent/CHANGELOG.md @@ -2,10 +2,23 @@ ## 2.1.0 +- Your component's live "data" is now send over Ajax as a JSON string. + Previously data was sent as pure query parameters or as pure POST data. + However, this made it impossible to keep certain data types, like + distinguishing between `null` and `''`. This has no impact on end-users. + +- Added `data-live-ignore` attribute. If included in an element, that element + will not be updated on re-render. + +- `ComponentWithFormTrait` no longer has a `setForm()` method. But there + is also no need to call it anymore. To pass an already-built form to + your component, pass it as a `form` var to `component()`. If you have + a custom `mount()`, you no longer need to call `setForm()` or anything else. + - The Live Component AJAX endpoints now return HTML in all situations instead of JSON. -- Send live action arguments to backend +- Ability to send live action arguments to backend - [BC BREAK] Remove `init_live_component()` twig function, use `{{ attributes }}` instead: ```diff diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 22b1c922484..ddc36e20d09 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -898,61 +898,6 @@ function combineSpacedArray(parts) { return finalParts; } -const buildFormKey = function (key, parentKeys) { - let fieldName = ''; - [...parentKeys, key].forEach((name) => { - fieldName += fieldName ? `[${name}]` : name; - }); - return fieldName; -}; -const addObjectToFormData = function (formData, data, parentKeys) { - Object.keys(data).forEach((key => { - let value = data[key]; - if (value === true) { - value = 1; - } - if (value === false) { - value = 0; - } - if (value === null) { - return; - } - if (typeof value === 'object') { - addObjectToFormData(formData, value, [...parentKeys, key]); - return; - } - formData.append(buildFormKey(key, parentKeys), value); - })); -}; -const addObjectToSearchParams = function (searchParams, data, parentKeys) { - Object.keys(data).forEach((key => { - let value = data[key]; - if (value === true) { - value = 1; - } - if (value === false) { - value = 0; - } - if (value === null) { - return; - } - if (typeof value === 'object') { - addObjectToSearchParams(searchParams, value, [...parentKeys, key]); - return; - } - searchParams.set(buildFormKey(key, parentKeys), value); - })); -}; -function buildFormData(data) { - const formData = new FormData(); - addObjectToFormData(formData, data, []); - return formData; -} -function buildSearchParams(searchParams, data) { - addObjectToSearchParams(searchParams, data, []); - return searchParams; -} - function setDeepData(data, propertyPath, value) { const finalData = JSON.parse(JSON.stringify(data)); let currentLevelData = finalData; @@ -963,7 +908,7 @@ function setDeepData(data, propertyPath, value) { const finalKey = parts[parts.length - 1]; if (typeof currentLevelData !== 'object') { const lastPart = parts.pop(); - throw new Error(`Cannot set data-model="${propertyPath}". They parent "${parts.join(',')}" data does not appear to be an object (it's "${currentLevelData}"). Did you forget to add exposed={"${lastPart}"} to its LiveProp?`); + throw new Error(`Cannot set data-model="${propertyPath}". The parent "${parts.join('.')}" data does not appear to be an object (it's "${currentLevelData}"). Did you forget to add exposed={"${lastPart}"} to its LiveProp?`); } if (currentLevelData[finalKey] === undefined) { const lastPart = parts.pop(); @@ -1014,6 +959,26 @@ function haveRenderedValuesChanged(originalDataJson, currentDataJson, newDataJso return keyHasChanged; } +function normalizeAttributesForComparison(element) { + if (element.value) { + element.setAttribute('value', element.value); + } + else if (element.hasAttribute('value')) { + element.setAttribute('value', ''); + } + Array.from(element.children).forEach((child) => { + normalizeAttributesForComparison(child); + }); +} + +function cloneHTMLElement(element) { + const newElement = element.cloneNode(true); + if (!(newElement instanceof HTMLElement)) { + throw new Error('Could not clone element'); + } + return newElement; +} + const DEFAULT_DEBOUNCE = 150; class default_1 extends Controller { constructor() { @@ -1112,23 +1077,12 @@ class default_1 extends Controller { this._makeRequest(null); } _getValueFromElement(element) { - const value = element.dataset.value || element.value; - if (!value) { - const clonedElement = (element.cloneNode()); - if (!(clonedElement instanceof HTMLElement)) { - throw new Error('cloneNode() produced incorrect type'); - } - throw new Error(`The update() method could not be called for "${clonedElement.outerHTML}": the element must either have a "data-value" or "value" attribute set.`); - } - return value; + return element.dataset.value || element.value; } _updateModelFromElement(element, value, shouldRender) { const model = element.dataset.model || element.getAttribute('name'); if (!model) { - const clonedElement = (element.cloneNode()); - if (!(clonedElement instanceof HTMLElement)) { - throw new Error('cloneNode() produced incorrect type'); - } + const clonedElement = cloneHTMLElement(element); throw new Error(`The update() method could not be called for "${clonedElement.outerHTML}": the element must either have a "data-model" or "name" attribute set to the model name.`); } this.$updateModel(model, value, shouldRender, element.hasAttribute('name') ? element.getAttribute('name') : null); @@ -1183,7 +1137,7 @@ class default_1 extends Controller { } const fetchOptions = {}; fetchOptions.headers = { - 'Accept': 'application/vnd.live-component+json', + 'Accept': 'application/vnd.live-component+html', }; if (action) { url += `/${encodeURIComponent(action)}`; @@ -1191,13 +1145,19 @@ class default_1 extends Controller { fetchOptions.headers['X-CSRF-TOKEN'] = this.csrfValue; } } - if (!action && this._willDataFitInUrl()) { - buildSearchParams(params, this.dataValue); - fetchOptions.method = 'GET'; + let dataAdded = false; + if (!action) { + const dataJson = JSON.stringify(this.dataValue); + if (this._willDataFitInUrl(dataJson, params)) { + params.set('data', dataJson); + fetchOptions.method = 'GET'; + dataAdded = true; + } } - else { + if (!dataAdded) { fetchOptions.method = 'POST'; - fetchOptions.body = buildFormData(this.dataValue); + fetchOptions.body = JSON.stringify(this.dataValue); + fetchOptions.headers['Content-Type'] = 'application/json'; } this._onLoadingStart(); const paramsString = params.toString(); @@ -1209,34 +1169,30 @@ class default_1 extends Controller { } const isMostRecent = this.renderPromiseStack.removePromise(thisPromise); if (isMostRecent) { - response.json().then((data) => { - this._processRerender(data); + response.text().then((html) => { + this._processRerender(html, response); }); } }); } - _processRerender(data) { + _processRerender(html, response) { if (this.isWindowUnloaded) { return; } - if (data.redirect_url) { + if (response.headers.get('Location')) { if (typeof Turbo !== 'undefined') { - Turbo.visit(data.redirect_url); + Turbo.visit(response.headers.get('Location')); } else { - window.location.href = data.redirect_url; + window.location.href = response.headers.get('Location') || ''; } return; } - if (!this._dispatchEvent('live:render', data, true, true)) { + if (!this._dispatchEvent('live:render', html, true, true)) { return; } this._onLoadingFinish(); - if (!data.html) { - throw new Error('Missing html key on response JSON'); - } - this._executeMorphdom(data.html); - this.dataValue = data.data; + this._executeMorphdom(html); } _clearWaitingDebouncedRenders() { if (this.renderDebounceTimeout) { @@ -1353,8 +1309,9 @@ class default_1 extends Controller { element.removeAttribute(attribute); }); } - _willDataFitInUrl() { - return Object.values(this.dataValue).join(',').length < 1500; + _willDataFitInUrl(dataJson, params) { + const urlEncodedJsonData = new URLSearchParams(dataJson).toString(); + return (urlEncodedJsonData + params.toString()).length < 1500; } _executeMorphdom(newHtml) { function htmlToElement(html) { @@ -1371,7 +1328,13 @@ class default_1 extends Controller { morphdom(this.element, newElement, { onBeforeElUpdated: (fromEl, toEl) => { if (fromEl.isEqualNode(toEl)) { - return false; + const normalizedFromEl = cloneHTMLElement(fromEl); + normalizeAttributesForComparison(normalizedFromEl); + const normalizedToEl = cloneHTMLElement(toEl); + normalizeAttributesForComparison(normalizedToEl); + if (normalizedFromEl.isEqualNode(normalizedToEl)) { + return false; + } } const controllerName = fromEl.hasAttribute('data-controller') ? fromEl.getAttribute('data-controller') : null; if (controllerName @@ -1380,6 +1343,9 @@ class default_1 extends Controller { && !this._shouldChildLiveElementUpdate(fromEl, toEl)) { return false; } + if (fromEl.hasAttribute('data-live-ignore')) { + return false; + } return true; } }); diff --git a/src/LiveComponent/assets/src/clone_html_element.ts b/src/LiveComponent/assets/src/clone_html_element.ts new file mode 100644 index 00000000000..b4ea6b5eecc --- /dev/null +++ b/src/LiveComponent/assets/src/clone_html_element.ts @@ -0,0 +1,8 @@ +export function cloneHTMLElement(element: HTMLElement): HTMLElement { + const newElement = element.cloneNode(true); + if (!(newElement instanceof HTMLElement)) { + throw new Error('Could not clone element'); + } + + return newElement; +} diff --git a/src/LiveComponent/assets/src/http_data_helper.ts b/src/LiveComponent/assets/src/http_data_helper.ts deleted file mode 100644 index b52ac9d0229..00000000000 --- a/src/LiveComponent/assets/src/http_data_helper.ts +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Helper to convert a deep object of data into a format - * that can be transmitted as GET or POST data. - * - * Likely there is an easier way to do this with no duplication. - */ - -const buildFormKey = function(key: string, parentKeys: string[]) { - let fieldName = ''; - [...parentKeys, key].forEach((name) => { - fieldName += fieldName ? `[${name}]` : name; - }); - - return fieldName; -} - -const addObjectToFormData = function(formData: FormData, data: any, parentKeys: string[]) { - // todo - handles files - Object.keys(data).forEach((key => { - let value = data[key]; - - // TODO: there is probably a better way to normalize this - if (value === true) { - value = 1; - } - if (value === false) { - value = 0; - } - // don't send null values at all - if (value === null) { - return; - } - - // handle embedded objects - if (typeof value === 'object') { - addObjectToFormData(formData, value, [...parentKeys, key]); - - return; - } - - formData.append(buildFormKey(key, parentKeys), value); - })); -} - -const addObjectToSearchParams = function(searchParams: URLSearchParams, data: any, parentKeys: string[]) { - Object.keys(data).forEach((key => { - let value = data[key]; - - // TODO: there is probably a better way to normalize this - // TODO: duplication - if (value === true) { - value = 1; - } - if (value === false) { - value = 0; - } - // don't send null values at all - if (value === null) { - return; - } - - // handle embedded objects - if (typeof value === 'object') { - addObjectToSearchParams(searchParams, value, [...parentKeys, key]); - - return; - } - - searchParams.set(buildFormKey(key, parentKeys), value); - })); -} - -/** - * @param {Object} data - * @return {FormData} - */ -export function buildFormData(data: any): FormData { - const formData = new FormData(); - - addObjectToFormData(formData, data, []); - - return formData; -} - -/** - * @param {URLSearchParams} searchParams - * @param {Object} data - * @return {URLSearchParams} - */ -export function buildSearchParams(searchParams: URLSearchParams, data: any): URLSearchParams { - addObjectToSearchParams(searchParams, data, []); - - return searchParams; -} diff --git a/src/LiveComponent/assets/src/live_controller.ts b/src/LiveComponent/assets/src/live_controller.ts index e240b35228e..680d8842a00 100644 --- a/src/LiveComponent/assets/src/live_controller.ts +++ b/src/LiveComponent/assets/src/live_controller.ts @@ -2,9 +2,10 @@ import { Controller } from '@hotwired/stimulus'; import morphdom from 'morphdom'; import { parseDirectives, Directive } from './directives_parser'; import { combineSpacedArray } from './string_utils'; -import { buildFormData, buildSearchParams } from './http_data_helper'; import { setDeepData, doesDeepPropertyExist, normalizeModelName } from './set_deep_data'; import { haveRenderedValuesChanged } from './have_rendered_values_changed'; +import { normalizeAttributesForComparison } from './normalize_attributes_for_comparison'; +import { cloneHTMLElement } from './clone_html_element'; interface ElementLoadingDirectives { element: HTMLElement, @@ -197,11 +198,7 @@ export default class extends Controller { const model = element.dataset.model || element.getAttribute('name'); if (!model) { - const clonedElement = (element.cloneNode()); - // helps typescript know this is an HTMLElement - if (!(clonedElement instanceof HTMLElement)) { - throw new Error('cloneNode() produced incorrect type'); - } + const clonedElement = cloneHTMLElement(element); throw new Error(`The update() method could not be called for "${clonedElement.outerHTML}": the element must either have a "data-model" or "name" attribute set to the model name.`); } @@ -317,12 +314,21 @@ export default class extends Controller { } } - if (!action && this._willDataFitInUrl()) { - buildSearchParams(params, this.dataValue); - fetchOptions.method = 'GET'; - } else { + let dataAdded = false; + if (!action) { + const dataJson = JSON.stringify(this.dataValue); + if (this._willDataFitInUrl(dataJson, params)) { + params.set('data', dataJson); + fetchOptions.method = 'GET'; + dataAdded = true; + } + } + + // if GET can't be used, fallback to POST + if (!dataAdded) { fetchOptions.method = 'POST'; - fetchOptions.body = buildFormData(this.dataValue); + fetchOptions.body = JSON.stringify(this.dataValue); + fetchOptions.headers['Content-Type'] = 'application/json'; } this._onLoadingStart(); @@ -533,9 +539,11 @@ export default class extends Controller { }) } - _willDataFitInUrl() { + _willDataFitInUrl(dataJson: string, params: URLSearchParams) { + const urlEncodedJsonData = new URLSearchParams(dataJson).toString(); + // if the URL gets remotely close to 2000 chars, it may not fit - return Object.values(this.dataValue).join(',').length < 1500; + return (urlEncodedJsonData + params.toString()).length < 1500; } _executeMorphdom(newHtml: string) { @@ -558,7 +566,18 @@ export default class extends Controller { onBeforeElUpdated: (fromEl, toEl) => { // https://github.com/patrick-steele-idem/morphdom#can-i-make-morphdom-blaze-through-the-dom-tree-even-faster-yes if (fromEl.isEqualNode(toEl)) { - return false + // the nodes are equal, but the "value" on some might differ + // lets try to quickly compare a bit more deeply + const normalizedFromEl = cloneHTMLElement(fromEl); + normalizeAttributesForComparison(normalizedFromEl); + + const normalizedToEl = cloneHTMLElement(toEl); + normalizeAttributesForComparison(normalizedToEl); + + if (normalizedFromEl.isEqualNode(normalizedToEl)) { + // don't bother updating + return false; + } } // avoid updating child components: they will handle themselves @@ -571,6 +590,11 @@ export default class extends Controller { return false; } + // look for data-live-ignore, and don't update + if (fromEl.hasAttribute('data-live-ignore')) { + return false; + } + return true; } }); diff --git a/src/LiveComponent/assets/src/normalize_attributes_for_comparison.ts b/src/LiveComponent/assets/src/normalize_attributes_for_comparison.ts new file mode 100644 index 00000000000..011de99400b --- /dev/null +++ b/src/LiveComponent/assets/src/normalize_attributes_for_comparison.ts @@ -0,0 +1,18 @@ +/** + * Updates an HTML node to represent its underlying data. + * + * For example, this finds the value property of each underlying node + * and sets that onto the value attribute. This is useful to compare + * if two nodes are identical. + */ +export function normalizeAttributesForComparison(element: HTMLElement): void { + if (element.value) { + element.setAttribute('value', element.value); + } else if (element.hasAttribute('value')) { + element.setAttribute('value', ''); + } + + Array.from(element.children).forEach((child: HTMLElement) => { + normalizeAttributesForComparison(child); + }); +} diff --git a/src/LiveComponent/assets/test/controller/action.test.ts b/src/LiveComponent/assets/test/controller/action.test.ts index 7f64a798a4e..fb4e69d4191 100644 --- a/src/LiveComponent/assets/test/controller/action.test.ts +++ b/src/LiveComponent/assets/test/controller/action.test.ts @@ -42,6 +42,9 @@ describe('LiveController Action Tests', () => { afterEach(() => { clearDOM(); + if (!fetchMock.done()) { + throw new Error('Mocked requests did not match'); + } fetchMock.reset(); }); @@ -62,16 +65,15 @@ describe('LiveController Action Tests', () => { await waitFor(() => expect(element).toHaveTextContent('Comment Saved!')); expect(getByLabelText(element, 'Comments:')).toHaveValue('hi weaver'); - fetchMock.done(); - - expect(postMock.lastOptions().body.get('comments')).toEqual('hi WEAVER'); + const bodyData = JSON.parse(postMock.lastOptions().body); + expect(bodyData.comments).toEqual('hi WEAVER'); }); it('Sends action named args', async () => { const data = { comments: 'hi' }; const { element } = await startStimulus(template(data)); - fetchMock.postOnce('http://localhost/_components/my_component/sendNamedArgs?values=a%3D1%26b%3D2%26c%3D3', { + fetchMock.postOnce('http://localhost/_components/my_component/sendNamedArgs?args=a%3D1%26b%3D2%26c%3D3', { html: template({ comments: 'hi' }), }); diff --git a/src/LiveComponent/assets/test/controller/child.test.ts b/src/LiveComponent/assets/test/controller/child.test.ts index 263d6ac2da9..f1e77698fdc 100644 --- a/src/LiveComponent/assets/test/controller/child.test.ts +++ b/src/LiveComponent/assets/test/controller/child.test.ts @@ -72,6 +72,9 @@ describe('LiveController parent -> child component tests', () => { afterEach(() => { clearDOM(); + if (!fetchMock.done()) { + throw new Error('Mocked requests did not match'); + } fetchMock.reset(); }); @@ -184,7 +187,7 @@ describe('LiveController parent -> child component tests', () => { const inputElement = getByLabelText(element, 'Content:'); await userEvent.clear(inputElement); await userEvent.type(inputElement, 'changed content'); - mockRerender({value: 'changed content'}, childTemplate); + mockRerender({value: 'changed content', error: null}, childTemplate); await waitFor(() => expect(element).toHaveTextContent('Value in child: changed content')); diff --git a/src/LiveComponent/assets/test/controller/csrf.test.ts b/src/LiveComponent/assets/test/controller/csrf.test.ts index 39958236fad..17896ce7d60 100644 --- a/src/LiveComponent/assets/test/controller/csrf.test.ts +++ b/src/LiveComponent/assets/test/controller/csrf.test.ts @@ -40,6 +40,9 @@ describe('LiveController CSRF Tests', () => { afterEach(() => { clearDOM(); + if (!fetchMock.done()) { + throw new Error('Mocked requests did not match'); + } fetchMock.reset(); }); @@ -56,7 +59,5 @@ describe('LiveController CSRF Tests', () => { await waitFor(() => expect(element).toHaveTextContent('Comment Saved!')); expect(postMock.lastOptions().headers['X-CSRF-TOKEN']).toEqual('123TOKEN'); - - fetchMock.done(); }); }); diff --git a/src/LiveComponent/assets/test/controller/model.test.ts b/src/LiveComponent/assets/test/controller/model.test.ts index 45e4485b4c3..937df1b10b3 100644 --- a/src/LiveComponent/assets/test/controller/model.test.ts +++ b/src/LiveComponent/assets/test/controller/model.test.ts @@ -34,6 +34,9 @@ describe('LiveController data-model Tests', () => { afterEach(() => { clearDOM(); + if (!fetchMock.done()) { + throw new Error('Mocked requests did not match'); + } fetchMock.reset(); }); @@ -41,7 +44,9 @@ describe('LiveController data-model Tests', () => { const data = { name: 'Ryan' }; const { element, controller } = await startStimulus(template(data)); - fetchMock.getOnce('end:?name=Ryan+WEAVER', template({ name: 'Ryan Weaver' })); + mockRerender({name: 'Ryan WEAVER'}, template, (data: any) => { + data.name = 'Ryan Weaver'; + }); await userEvent.type(getByLabelText(element, 'Name:'), ' WEAVER', { // this tests the debounce: characters have a 10ms delay @@ -52,9 +57,6 @@ describe('LiveController data-model Tests', () => { await waitFor(() => expect(getByLabelText(element, 'Name:')).toHaveValue('Ryan Weaver')); expect(controller.dataValue).toEqual({name: 'Ryan Weaver'}); - // assert all calls were done the correct number of times - fetchMock.done(); - // assert the input is still focused after rendering expect(document.activeElement.dataset.model).toEqual('name'); }); @@ -63,15 +65,12 @@ describe('LiveController data-model Tests', () => { const data = { name: 'Ryan' }; const { element, controller } = await startStimulus(template(data)); - fetchMock.getOnce('end:?name=Jan', template({ name: 'Jan' })); + mockRerender({name: 'Jan'}, template); userEvent.click(getByText(element, 'Change name to Jan')); await waitFor(() => expect(getByLabelText(element, 'Name:')).toHaveValue('Jan')); expect(controller.dataValue).toEqual({name: 'Jan'}); - - // assert all calls were done the correct number of times - fetchMock.done(); }); @@ -90,11 +89,9 @@ describe('LiveController data-model Tests', () => { ['guy', 150] ]; requests.forEach(([string, delay]) => { - fetchMock.getOnce( - `end:my_component?name=Ryan${string}`, - template({ name: `Ryan${string}_` }), - { delay } - ); + mockRerender({name: `Ryan${string}`}, template, (data: any) => { + data.name = `Ryan${string}_`; + }, { delay }); }); await userEvent.type(getByLabelText(element, 'Name:'), 'guy', { @@ -111,9 +108,6 @@ describe('LiveController data-model Tests', () => { await waitFor(() => expect(getByLabelText(element, 'Name:')).toHaveValue('Ryanguy_')); expect(controller.dataValue).toEqual({name: 'Ryanguy_'}); - // assert all calls were done the correct number of times - fetchMock.done(); - // only 1 render should have ultimately occurred expect(renderCount).toEqual(1); }); @@ -127,7 +121,7 @@ describe('LiveController data-model Tests', () => { delete inputElement.dataset.model; inputElement.setAttribute('name', 'name'); - mockRerender({name: 'Ryan WEAVER'}, template, (data) => { + mockRerender({name: 'Ryan WEAVER'}, template, (data: any) => { data.name = 'Ryan Weaver'; }); @@ -135,9 +129,6 @@ describe('LiveController data-model Tests', () => { await waitFor(() => expect(inputElement).toHaveValue('Ryan Weaver')); expect(controller.dataValue).toEqual({name: 'Ryan Weaver'}); - - // assert all calls were done the correct number of times - fetchMock.done(); }); it('uses data-model when both name and data-model is present', async () => { @@ -157,8 +148,6 @@ describe('LiveController data-model Tests', () => { await waitFor(() => expect(inputElement).toHaveValue('Ryan Weaver')); expect(controller.dataValue).toEqual({name: 'Ryan Weaver'}); - - fetchMock.done(); }); it('uses data-value when both value and data-value is present', async () => { @@ -178,8 +167,6 @@ describe('LiveController data-model Tests', () => { await waitFor(() => expect(inputElement).toHaveValue('first_name')); expect(controller.dataValue).toEqual({name: 'first_name'}); - - fetchMock.done(); }); it('standardizes user[firstName] style models into post.name', async () => { @@ -211,9 +198,6 @@ describe('LiveController data-model Tests', () => { await waitFor(() => expect(inputElement).toHaveValue('Ryan Weaver')); expect(controller.dataValue).toEqual({ user: { firstName: 'Ryan Weaver' } }); - - // assert all calls were done the correct number of times - fetchMock.done(); }); it('updates correctly when live#update is on a parent element', async () => { @@ -245,8 +229,6 @@ describe('LiveController data-model Tests', () => { await waitFor(() => expect(inputElement).toHaveValue('Ryan Weaver')); - fetchMock.done(); - // assert the input is still focused after rendering expect(document.activeElement.getAttribute('name')).toEqual('firstName'); }); @@ -264,9 +246,6 @@ describe('LiveController data-model Tests', () => { await userEvent.type(inputElement, ' WEAVER'); await waitFor(() => expect(inputElement).toHaveValue('Ryan Weaver')); - - // assert all calls were done the correct number of times - fetchMock.done(); }); it('data changed on server should be noticed by controller', async () => { @@ -283,7 +262,5 @@ describe('LiveController data-model Tests', () => { await waitFor(() => expect(inputElement).toHaveValue('Kevin Bond')); expect(controller.dataValue).toEqual({name: 'Kevin Bond'}); - - fetchMock.done(); }); }); diff --git a/src/LiveComponent/assets/test/controller/render.test.ts b/src/LiveComponent/assets/test/controller/render.test.ts index 505842472dd..8b6cc6befa1 100644 --- a/src/LiveComponent/assets/test/controller/render.test.ts +++ b/src/LiveComponent/assets/test/controller/render.test.ts @@ -40,6 +40,9 @@ describe('LiveController rendering Tests', () => { afterEach(() => { clearDOM(); + if (!fetchMock.done()) { + throw new Error('Mocked requests did not match'); + } fetchMock.reset(); }); @@ -57,19 +60,40 @@ describe('LiveController rendering Tests', () => { expect(controller.dataValue).toEqual({name: 'Kevin'}); }); - it('conserves values of fields modified after a render request', async () => { + it('renders over local value in input', async () => { const data = { name: 'Ryan' }; const { element } = await startStimulus(template(data)); - fetchMock.get( - 'http://localhost/_components/my_component?name=Ryan', - template({ name: 'Kevin' }), - { delay: 100 } - ); - getByText(element, 'Reload').click(); + mockRerender({name: 'Ryan'}, template, (data: any) => { + data.name = 'Kevin'; + }, { delay: 100 }); + // type into the input that is not bound to a model userEvent.type(getByLabelText(element, 'Comments:'), '!!'); + getByText(element, 'Reload').click(); await waitFor(() => expect(element).toHaveTextContent('Name: Kevin')); + // value if unmapped input is reset + expect(getByLabelText(element, 'Comments:')).toHaveValue('i like pizza'); + expect(document.activeElement.name).toEqual('comments'); + }); + + it('conserves values of fields modified after a render request IF data-live-ignore', async () => { + const data = { name: 'Ryan' }; + const { element } = await startStimulus(template(data)); + + // name=Ryan is sent to the server + mockRerender({name: 'Ryan'}, template, (data: any) => { + data.name = 'Kevin'; + }, { delay: 100 }); + + // type into the input that is not bound to a model + const input = getByLabelText(element, 'Comments:'); + input.setAttribute('data-live-ignore', ''); + userEvent.type(input, '!!'); + getByText(element, 'Reload').click(); + + await waitFor(() => expect(element).toHaveTextContent('Name: Kevin')); + // value of unmapped input is NOT reset because of data-live-ignore expect(getByLabelText(element, 'Comments:')).toHaveValue('i like pizza!!'); expect(document.activeElement.name).toEqual('comments'); }); @@ -83,13 +107,18 @@ describe('LiveController rendering Tests', () => { template(data, true) ); - fetchMock.get( - 'http://localhost/_components/my_component?name=Ryan', - template({ name: 'Kevin' }, true), + mockRerender( + { name: 'Ryan' }, + // re-render but passing true as the second arg + (data: any) => template(data, true), + (data: any) => { data.name = 'Kevin'; }, { delay: 100 } ); + + const input = getByLabelText(element, 'Comments:'); + input.setAttribute('data-live-ignore', ''); + userEvent.type(input, '!!'); getByText(element, 'Reload').click(); - userEvent.type(getByLabelText(element, 'Comments:'), '!!'); await waitFor(() => expect(element).toHaveTextContent('Name: Kevin')); expect(getByLabelText(element, 'Comments:')).toHaveValue('i like pizza!!'); @@ -100,9 +129,12 @@ describe('LiveController rendering Tests', () => { const data = { name: 'Ryan' }; const { element } = await startStimulus(template(data)); - fetchMock.get('end:?name=Ryan', '
aloha!
', { - delay: 100 - }); + mockRerender( + { name: 'Ryan' }, + () => '
aloha!
', + () => { }, + { delay: 100 } + ); getByText(element, 'Reload').click(); // imitate navigating away diff --git a/src/LiveComponent/assets/test/normalize_attributes_for_comparison.test.ts b/src/LiveComponent/assets/test/normalize_attributes_for_comparison.test.ts new file mode 100644 index 00000000000..f4b83e6a790 --- /dev/null +++ b/src/LiveComponent/assets/test/normalize_attributes_for_comparison.test.ts @@ -0,0 +1,64 @@ +import { normalizeAttributesForComparison } from '../src/normalize_attributes_for_comparison'; + +const createElement = function(html: string): HTMLElement { + const template = document.createElement('template'); + html = html.trim(); + template.innerHTML = html; + + const child = template.content.firstChild; + if (!child || !(child instanceof HTMLElement)) { + throw new Error('Child not found'); + } + + return child; +} + +describe('normalizeAttributesForComparison', () => { + it('makes no changes if value and attribute not set', () => { + const element = createElement('
'); + normalizeAttributesForComparison(element); + expect(element.outerHTML) + .toEqual('
'); + }); + + it('sets the attribute if the value is present', () => { + const element = createElement(''); + element.value = 'set value'; + normalizeAttributesForComparison(element); + expect(element.outerHTML) + .toEqual(''); + }); + + it('sets the attribute to empty if the value is empty', () => { + const element = createElement(''); + element.value = ''; + normalizeAttributesForComparison(element); + expect(element.outerHTML) + .toEqual(''); + }); + + it('changes the value attribute if value is different', () => { + const element = createElement(''); + element.value = 'changed'; + normalizeAttributesForComparison(element); + expect(element.outerHTML) + .toEqual(''); + }); + + it('changes the value attribute on a child', () => { + const element = createElement('
'); + element.querySelector('#child').value = 'changed'; + normalizeAttributesForComparison(element); + expect(element.outerHTML) + .toEqual('
'); + }); + + it('changes the value on multiple levels', () => { + const element = createElement('
'); + element.querySelector('#child').value = 'changed'; + element.querySelector('#grand_child').value = 'changed grand child'; + normalizeAttributesForComparison(element); + expect(element.outerHTML) + .toEqual('
'); + }); +}); diff --git a/src/LiveComponent/assets/test/tools.ts b/src/LiveComponent/assets/test/tools.ts index 4e70af6e376..b3185ac9a92 100644 --- a/src/LiveComponent/assets/test/tools.ts +++ b/src/LiveComponent/assets/test/tools.ts @@ -2,7 +2,7 @@ import { Application } from '@hotwired/stimulus'; import LiveController from '../src/live_controller'; import { waitFor } from '@testing-library/dom'; import fetchMock from 'fetch-mock-jest'; -import { buildSearchParams } from '../src/http_data_helper'; +import MockOptions = jest.MockOptions; const TestData = class { constructor(controller, element) { @@ -64,17 +64,20 @@ const initLiveComponent = (url, data) => { * @param {Object} sentData The *expected* data that should be sent to the server * @param {function} renderCallback Function that will render the component * @param {function|null} changeDataCallback Specify if you want to change the data before rendering + * @param {MockOptions} options Options passed to fetchMock */ -const mockRerender = (sentData, renderCallback, changeDataCallback = null) => { - const params = new URLSearchParams(''); +const mockRerender = (sentData: any, renderCallback, changeDataCallback = null, options: MockOptions = {}) => { + const params = new URLSearchParams({ + data: JSON.stringify(sentData) + }); - const url = `end:?${buildSearchParams(params, sentData).toString()}`; + const url = `end:?${params.toString()}`; if (changeDataCallback) { changeDataCallback(sentData); } - fetchMock.mock(url, renderCallback(sentData)); + fetchMock.mock(url, renderCallback(sentData), options); } export { startStimulus, getControllerElement, initLiveComponent, mockRerender }; diff --git a/src/LiveComponent/composer.json b/src/LiveComponent/composer.json index 21ff0e06f7e..5e9c66a0942 100644 --- a/src/LiveComponent/composer.json +++ b/src/LiveComponent/composer.json @@ -34,6 +34,7 @@ "doctrine/doctrine-bundle": "^2.0", "doctrine/orm": "^2.7", "symfony/dependency-injection": "^5.4|^6.0", + "symfony/form": "^5.4|^6.0", "symfony/framework-bundle": "^5.4|^6.0", "symfony/phpunit-bridge": "^6.0", "symfony/security-csrf": "^5.4|^6.0", diff --git a/src/LiveComponent/phpunit.xml.dist b/src/LiveComponent/phpunit.xml.dist index c86748b796a..54548d66017 100644 --- a/src/LiveComponent/phpunit.xml.dist +++ b/src/LiveComponent/phpunit.xml.dist @@ -10,7 +10,7 @@ > - + diff --git a/src/LiveComponent/src/ComponentWithFormTrait.php b/src/LiveComponent/src/ComponentWithFormTrait.php index bcd0f20092e..0e23ee2cbf1 100644 --- a/src/LiveComponent/src/ComponentWithFormTrait.php +++ b/src/LiveComponent/src/ComponentWithFormTrait.php @@ -17,6 +17,8 @@ use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; use Symfony\UX\LiveComponent\Attribute\BeforeReRender; use Symfony\UX\LiveComponent\Attribute\LiveProp; +use Symfony\UX\LiveComponent\Util\LiveFormUtility; +use Symfony\UX\TwigComponent\Attribute\PostMount; /** * @author Ryan Weaver @@ -63,17 +65,29 @@ trait ComponentWithFormTrait */ abstract protected function instantiateForm(): FormInterface; - /** - * Override in your class if you need extra mounted values. - * - * Call $this->setForm($form) manually in that situation - * if you're passing in an initial form. - */ - public function mount(?FormView $form = null) + #[PostMount] + public function postMount(array $data): array { - if ($form) { - $this->setForm($form); + // allow the FormView object to be passed into the component() as "form" + if (\array_key_exists('form', $data)) { + $this->formView = $data['form']; + unset($data['form']); + + if ($this->formView) { + // if a FormView is passed in and it contains any errors, then + // we mark that this entire component has been validated so that + // all validation errors continue showing on re-render + if (LiveFormUtility::doesFormContainAnyErrors($this->formView)) { + $this->isValidated = true; + $this->validatedFields = []; + } + } } + + // set the formValues from the initial form view's data + $this->initializeFormValues(); + + return $data; } /** @@ -87,7 +101,7 @@ public function mount(?FormView $form = null) public function submitFormOnRender(): void { if (!$this->getFormInstance()->isSubmitted()) { - $this->submitForm(false); + $this->submitForm($this->isValidated); } } @@ -103,18 +117,6 @@ public function getForm(): FormView return $this->formView; } - /** - * Call this from mount() if your component receives a FormView. - * - * If your are not passing a FormView into your component, you - * don't need to call this directly: the form will be set for - * you from your instantiateForm() method. - */ - public function setForm(FormView $form): void - { - $this->formView = $form; - } - public function getFormName(): string { if (!$this->formName) { @@ -124,18 +126,19 @@ public function getFormName(): string return $this->formName; } - public function getFormValues(): array + private function initializeFormValues(): void { - if (null === $this->formValues) { - $this->formValues = $this->extractFormValues($this->getForm()); - } - - return $this->formValues; + $this->formValues = $this->extractFormValues($this->getForm()); } private function submitForm(bool $validateAll = true): void { - $this->getFormInstance()->submit($this->formValues); + if (null !== $this->formView) { + throw new \LogicException('The submitForm() method is being called, but the FormView has already been built. Are you calling $this->getForm() - which creates the FormView - before submitting the form?'); + } + + $form = $this->getFormInstance(); + $form->submit($this->formValues); if ($validateAll) { // mark the entire component as validated @@ -146,10 +149,19 @@ private function submitForm(bool $validateAll = true): void // we only want to validate fields in validatedFields // but really, everything is validated at this point, which // means we need to clear validation on non-matching fields - $this->clearErrorsForNonValidatedFields($this->getFormInstance(), $this->getFormName()); + $this->clearErrorsForNonValidatedFields($form, $form->getName()); } - if (!$this->getFormInstance()->isValid()) { + // re-extract the "view" values in case the submitted data + // changed the underlying data or structure of the form + $this->formValues = $this->extractFormValues($this->getForm()); + // remove any validatedFields that do not exist in data anymore + $this->validatedFields = LiveFormUtility::removePathsNotInData( + $this->validatedFields ?? [], + [$form->getName() => $this->formValues], + ); + + if (!$form->isValid()) { throw new UnprocessableEntityHttpException('Form validation failed in component'); } } @@ -166,7 +178,13 @@ private function extractFormValues(FormView $formView): array $values = []; foreach ($formView->children as $child) { $name = $child->vars['name']; - if (\count($child->children) > 0) { + + // if there are children, expand their values recursively + // UNLESS the field is "expanded": in that case the value + // is already correct. For example, an expanded ChoiceType with + // options "text" and "phone" would already have a value in the format + // ["text"] (assuming "text" is checked and "phone" is not). + if (!($child->vars['expanded'] ?? false) && \count($child->children) > 0) { $values[$name] = $this->extractFormValues($child); continue; @@ -192,7 +210,7 @@ private function getFormInstance(): FormInterface return $this->formInstance; } - private function clearErrorsForNonValidatedFields(Form $form, $currentPath = ''): void + private function clearErrorsForNonValidatedFields(Form $form, string $currentPath = ''): void { if (!$currentPath || !\in_array($currentPath, $this->validatedFields, true)) { $form->clearErrors(); diff --git a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php index 1f8ed8c8bd9..2fa51fecd49 100644 --- a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php +++ b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php @@ -16,8 +16,6 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Extension\Extension; use Symfony\Component\DependencyInjection\Reference; -use Symfony\Component\Routing\Generator\UrlGeneratorInterface; -use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\ComponentValidator; use Symfony\UX\LiveComponent\ComponentValidatorInterface; @@ -71,7 +69,7 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) { ->addTag('container.service_subscriber', ['key' => ComponentFactory::class, 'id' => 'ux.twig_component.component_factory']) ->addTag('container.service_subscriber', ['key' => ComponentRenderer::class, 'id' => 'ux.twig_component.component_renderer']) ->addTag('container.service_subscriber', ['key' => LiveComponentHydrator::class, 'id' => 'ux.live_component.component_hydrator']) - ->addTag('container.service_subscriber') + ->addTag('container.service_subscriber') // csrf ; $container->register('ux.live_component.twig.component_extension', LiveComponentTwigExtension::class) @@ -80,11 +78,9 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) { $container->register('ux.live_component.twig.component_runtime', LiveComponentRuntime::class) ->setArguments([ - new Reference('twig'), new Reference('ux.live_component.component_hydrator'), new Reference('ux.twig_component.component_factory'), - new Reference(UrlGeneratorInterface::class), - new Reference(CsrfTokenManagerInterface::class, ContainerBuilder::NULL_ON_INVALID_REFERENCE), + new Reference('router'), ]) ->addTag('twig.runtime') ; @@ -95,7 +91,8 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) { $container->register('ux.live_component.add_attributes_subscriber', AddLiveAttributesSubscriber::class) ->addTag('kernel.event_subscriber') - ->addTag('container.service_subscriber', ['key' => LiveComponentRuntime::class, 'id' => 'ux.live_component.twig.component_runtime']) + ->addTag('container.service_subscriber', ['key' => LiveComponentHydrator::class, 'id' => 'ux.live_component.component_hydrator']) + ->addTag('container.service_subscriber') // csrf, twig & router ; $container->setAlias(ComponentValidatorInterface::class, ComponentValidator::class); diff --git a/src/LiveComponent/src/EventListener/AddLiveAttributesSubscriber.php b/src/LiveComponent/src/EventListener/AddLiveAttributesSubscriber.php index ab7297623cc..02213565be9 100644 --- a/src/LiveComponent/src/EventListener/AddLiveAttributesSubscriber.php +++ b/src/LiveComponent/src/EventListener/AddLiveAttributesSubscriber.php @@ -4,10 +4,14 @@ use Psr\Container\ContainerInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; use Symfony\Contracts\Service\ServiceSubscriberInterface; -use Symfony\UX\LiveComponent\Twig\LiveComponentRuntime; +use Symfony\UX\LiveComponent\LiveComponentHydrator; use Symfony\UX\TwigComponent\ComponentAttributes; use Symfony\UX\TwigComponent\EventListener\PreRenderEvent; +use Symfony\UX\TwigComponent\MountedComponent; +use Twig\Environment; /** * @author Kevin Bond @@ -25,11 +29,7 @@ public function onPreRender(PreRenderEvent $event): void return; } - /** @var ComponentAttributes $attributes */ - $attributes = $this->container->get(LiveComponentRuntime::class) - ->getLiveAttributes($event->getComponent(), $event->getMetadata()) - ; - + $attributes = $this->getLiveAttributes($event->getMountedComponent()); $variables = $event->getVariables(); if (isset($variables['attributes']) && $variables['attributes'] instanceof ComponentAttributes) { @@ -50,7 +50,32 @@ public static function getSubscribedEvents(): array public static function getSubscribedServices(): array { return [ - LiveComponentRuntime::class, + LiveComponentHydrator::class, + UrlGeneratorInterface::class, + Environment::class, + '?'.CsrfTokenManagerInterface::class, ]; } + + private function getLiveAttributes(MountedComponent $mounted): ComponentAttributes + { + $name = $mounted->getName(); + $url = $this->container->get(UrlGeneratorInterface::class)->generate('live_component', ['component' => $name]); + $data = $this->container->get(LiveComponentHydrator::class)->dehydrate($mounted); + $twig = $this->container->get(Environment::class); + + $attributes = [ + 'data-controller' => 'live', + 'data-live-url-value' => twig_escape_filter($twig, $url, 'html_attr'), + 'data-live-data-value' => twig_escape_filter($twig, json_encode($data, \JSON_THROW_ON_ERROR), 'html_attr'), + ]; + + if ($this->container->has(CsrfTokenManagerInterface::class)) { + $attributes['data-live-csrf-value'] = $this->container->get(CsrfTokenManagerInterface::class) + ->getToken($name)->getValue() + ; + } + + return new ComponentAttributes($attributes); + } } diff --git a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php index dd54f30f051..a89a7fcef69 100644 --- a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php +++ b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php @@ -33,6 +33,7 @@ use Symfony\UX\TwigComponent\ComponentFactory; use Symfony\UX\TwigComponent\ComponentMetadata; use Symfony\UX\TwigComponent\ComponentRenderer; +use Symfony\UX\TwigComponent\MountedComponent; /** * @author Kevin Bond @@ -73,6 +74,8 @@ public function onKernelRequest(RequestEvent $event): void $action = $request->get('action', 'get'); $componentName = (string) $request->get('component'); + $request->attributes->set('_component_name', $componentName); + try { /** @var ComponentMetadata $metadata */ $metadata = $this->container->get(ComponentFactory::class)->metadataFor($componentName); @@ -84,8 +87,6 @@ public function onKernelRequest(RequestEvent $event): void throw new NotFoundHttpException(sprintf('"%s" (%s) is not a Live Component.', $metadata->getClass(), $componentName)); } - $request->attributes->set('_component_metadata', $metadata); - if ('get' === $action) { $defaultAction = trim($metadata->get('default_action', '__invoke'), '()'); @@ -122,10 +123,13 @@ public function onKernelController(ControllerEvent $event): void return; } - $data = array_merge( - $request->query->all(), - $request->request->all() - ); + if ($request->query->has('data')) { + // ?data= + $data = json_decode($request->query->get('data'), true, 512, \JSON_THROW_ON_ERROR); + } else { + // OR body of the request is JSON + $data = json_decode($request->getContent(), true, 512, \JSON_THROW_ON_ERROR); + } if (!\is_array($controller = $event->getController()) || 2 !== \count($controller)) { throw new \RuntimeException('Not a valid live component.'); @@ -141,9 +145,13 @@ public function onKernelController(ControllerEvent $event): void throw new NotFoundHttpException(sprintf('The action "%s" either doesn\'t exist or is not allowed in "%s". Make sure it exist and has the LiveAction attribute above it.', $action, \get_class($component))); } - $this->container->get(LiveComponentHydrator::class)->hydrate($component, $data); + $mounted = $this->container->get(LiveComponentHydrator::class)->hydrate( + $component, + $data, + $request->attributes->get('_component_name') + ); - $request->attributes->set('_component', $component); + $request->attributes->set('_mounted_component', $mounted); if (!\is_string($queryString = $request->query->get('args'))) { return; @@ -167,7 +175,7 @@ public function onKernelView(ViewEvent $event): void return; } - $response = $this->createResponse($request->attributes->get('_component'), $request); + $response = $this->createResponse($request->attributes->get('_mounted_component'), $request); $event->setResponse($response); } @@ -184,14 +192,14 @@ public function onKernelException(ExceptionEvent $event): void return; } - $component = $request->attributes->get('_component'); + $mounted = $request->attributes->get('_mounted_component'); // in case the exception was too early somehow - if (!$component) { + if (!$mounted) { return; } - $response = $this->createResponse($component, $request); + $response = $this->createResponse($mounted, $request); $event->setResponse($response); } @@ -229,15 +237,16 @@ public static function getSubscribedEvents(): array ]; } - private function createResponse(object $component, Request $request): Response + private function createResponse(MountedComponent $mounted, Request $request): Response { + $component = $mounted->getComponent(); + foreach (AsLiveComponent::beforeReRenderMethods($component) as $method) { $component->{$method->name}(); } $html = $this->container->get(ComponentRenderer::class)->render( - $component, - $request->attributes->get('_component_metadata') + $mounted, ); return new Response($html); diff --git a/src/LiveComponent/src/LiveComponentHydrator.php b/src/LiveComponent/src/LiveComponentHydrator.php index e79ced7a9f0..5d7e76207d0 100644 --- a/src/LiveComponent/src/LiveComponentHydrator.php +++ b/src/LiveComponent/src/LiveComponentHydrator.php @@ -17,6 +17,8 @@ use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\Attribute\LivePropContext; use Symfony\UX\LiveComponent\Exception\UnsupportedHydrationException; +use Symfony\UX\TwigComponent\ComponentAttributes; +use Symfony\UX\TwigComponent\MountedComponent; /** * @author Kevin Bond @@ -29,6 +31,7 @@ final class LiveComponentHydrator { private const CHECKSUM_KEY = '_checksum'; private const EXPOSED_PROP_KEY = '_id'; + private const ATTRIBUTES_KEY = '_attributes'; /** @var PropertyHydratorInterface[] */ private iterable $propertyHydrators; @@ -45,8 +48,10 @@ public function __construct(iterable $propertyHydrators, PropertyAccessorInterfa $this->secret = $secret; } - public function dehydrate(object $component): array + public function dehydrate(MountedComponent $mounted): array { + $component = $mounted->getComponent(); + foreach (AsLiveComponent::preDehydrateMethods($component) as $method) { $component->{$method->name}(); } @@ -100,15 +105,24 @@ public function dehydrate(object $component): array } } + if ($attributes = $mounted->getAttributes()->all()) { + $data[self::ATTRIBUTES_KEY] = $attributes; + $readonlyProperties[] = self::ATTRIBUTES_KEY; + } + $data[self::CHECKSUM_KEY] = $this->computeChecksum($data, $readonlyProperties); return $data; } - public function hydrate(object $component, array $data): void + public function hydrate(object $component, array $data, string $componentName): MountedComponent { $readonlyProperties = []; + if (isset($data[self::ATTRIBUTES_KEY])) { + $readonlyProperties[] = self::ATTRIBUTES_KEY; + } + /** @var LivePropContext[] $propertyContexts */ $propertyContexts = iterator_to_array(AsLiveComponent::liveProps($component)); @@ -129,7 +143,9 @@ public function hydrate(object $component, array $data): void $this->verifyChecksum($data, $readonlyProperties); - unset($data[self::CHECKSUM_KEY]); + $attributes = new ComponentAttributes($data[self::ATTRIBUTES_KEY] ?? []); + + unset($data[self::CHECKSUM_KEY], $data[self::ATTRIBUTES_KEY]); foreach ($propertyContexts as $context) { $property = $context->reflectionProperty(); @@ -187,6 +203,8 @@ public function hydrate(object $component, array $data): void foreach (AsLiveComponent::postHydrateMethods($component) as $method) { $component->{$method->name}(); } + + return new MountedComponent($componentName, $component, $attributes); } private function computeChecksum(array $data, array $readonlyProperties): string diff --git a/src/LiveComponent/src/Resources/doc/index.rst b/src/LiveComponent/src/Resources/doc/index.rst index 41891718cc3..6b161ead31a 100644 --- a/src/LiveComponent/src/Resources/doc/index.rst +++ b/src/LiveComponent/src/Resources/doc/index.rst @@ -114,7 +114,7 @@ Oh, and just one more step! Import a routing file from the bundle: That's it! We're ready! -Making your Component “Live” +Making your Component "Live" ---------------------------- If you haven't already, check out the `Twig Component`_ @@ -143,7 +143,7 @@ Suppose you've already built a basic Twig component:: {{ this.randomNumber }} -To transform this into a “live” component (i.e. one that can be +To transform this into a "live" component (i.e. one that can be re-rendered live on the frontend), replace the component's ``AsTwigComponent`` attribute with ``AsLiveComponent`` and add the ``DefaultActionTrait``: @@ -231,14 +231,14 @@ when rendering the component: {{ component('random_number', { min: 5, max: 500 }) }} But what's up with those ``LiveProp`` attributes? A property with the -``LiveProp`` attribute becomes a “stateful” property for this component. -In other words, each time we click the “Generate a new number!” button, +``LiveProp`` attribute becomes a "stateful" property for this component. +In other words, each time we click the "Generate a new number!" button, when the component re-renders, it will *remember* the original values for the ``$min`` and ``$max`` properties and generate a random number between 5 and 500. If you forgot to add ``LiveProp``, when the component re-rendered, those two values would *not* be set on the object. -In short: LiveProps are “stateful properties”: they will always be set +In short: LiveProps are "stateful properties": they will always be set when rendering. Most properties will be LiveProps, with common exceptions being properties that hold services (these don't need to be stateful because they will be autowired each time before the component @@ -249,30 +249,15 @@ Component Attributes .. versionadded:: 2.1 - The ``HasAttributes`` trait was added in TwigComponents 2.1. + Component attributes were added in TwigComponents 2.1. `Component attributes`_ allows you to render your components with extra props that are are converted to html attributes and made available in your component's template as an ``attributes`` variable. When used on -live components, these props are persisted between renders. You can enable -this feature by having your live component use the ``HasAttributesTrait``: +live components, these props are persisted between renders. -.. code-block:: diff - - // ... - use Symfony\UX\LiveComponent\Attribute\LiveProp; - + use Symfony\UX\TwigComponent\HasAttributesTrait; - - #[AsLiveComponent('random_number')] - class RandomNumberComponent - { - + use HasAttributesTrait; - - #[LiveProp] - public int $min = 0; - -Now, when rendering your component, you can pass html attributes -as props and these will be added to ``attributes``: +When rendering your component, you can pass html attributes as props and +these will be added to ``attributes``: .. code-block:: twig @@ -282,7 +267,7 @@ as props and these will be added to ``attributes``:
> -data-action=“live#update”: Re-rendering on LiveProp Change +data-action="live#update": Re-rendering on LiveProp Change ---------------------------------------------------------- Could we allow the user to *choose* the ``$min`` and ``$max`` values and @@ -320,7 +305,7 @@ that field! Yes, as you type in a box, the component automatically updates to reflect the new number! Well, actually, we're missing one step. By default, a ``LiveProp`` is -“read only”. For security purposes, a user cannot change the value of a +"read only". For security purposes, a user cannot change the value of a ``LiveProp`` and re-render the component unless you allow it with the ``writable=true`` option: @@ -354,7 +339,7 @@ method has built-in debouncing: it waits for a 150ms pause before sending an Ajax request to re-render. This is built in, so you don't need to think about it. -Lazy Updating on “blur” or “change” of a Field +Lazy Updating on "blur" or "change" of a Field ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Sometimes, you might want a field to re-render only after the user has @@ -375,7 +360,7 @@ happens, add it to the ``data-action`` call: The ``data-action="change->live#update"`` syntax is standard Stimulus syntax, which says: - When the “change” event occurs, call the ``update`` method on the + When the "change" event occurs, call the ``update`` method on the ``live`` controller. .. _deferring-a-re-render-until-later: @@ -397,7 +382,7 @@ clicked). To do that, use the ``updateDefer`` method: + data-action="live#updateDefer" > -Now, as you type, the ``max`` “model” will be updated in JavaScript, but +Now, as you type, the ``max`` "model" will be updated in JavaScript, but it won't, yet, make an Ajax call to re-render the component. Whenever the next re-render *does* happen, the updated ``max`` value will be used. @@ -500,7 +485,7 @@ changes until loading has taken longer than a certain amount of time: Actions ------- -Live components require a single “default action” that is used to +Live components require a single "default action" that is used to re-render it. By default, this is an empty ``__invoke()`` method and can be added with the ``DefaultActionTrait``. Live components are actually Symfony controllers so you can add the normal controller @@ -508,7 +493,7 @@ attributes/annotations (ie ``@Cache``/``@Security``) to either the entire class just a single action. You can also trigger custom actions on your component. Let's pretend we -want to add a “Reset Min/Max” button to our “random number” component +want to add a "Reset Min/Max" button to our "random number" component that, when clicked, sets the min/max numbers back to a default value. First, add a method with a ``LiveAction`` attribute above it that does @@ -549,7 +534,7 @@ will trigger the ``resetMinMax()`` method! After calling that method, the component will re-render like normal, using the new ``$min`` and ``$max`` properties! -You can also add several “modifiers” to the action: +You can also add several "modifiers" to the action: .. code-block:: twig @@ -562,7 +547,7 @@ You can also add several “modifiers” to the action: The ``prevent`` modifier would prevent the form from submitting (``event.preventDefault()``). The ``debounce(300)`` modifier will add -300ms of “debouncing” before the action is executed. In other words, if +300ms of "debouncing" before the action is executed. In other words, if you click really fast 5 times, only one Ajax request will be made! Actions & Services @@ -807,9 +792,9 @@ make it easy to deal with forms:: * The `fieldName` option is needed in this situation because * the form renders fields with names like `name="post[title]"`. * We set `fieldName: ''` so that this live prop doesn't collide - * with that data. The value - initialFormData - could be anything. + * with that data. The value - data - could be anything. */ - #[LiveProp(fieldName: 'initialFormData')] + #[LiveProp(fieldName: 'data')] public ?Post $post = null; /** @@ -881,14 +866,14 @@ This is possible thanks to a few interesting pieces: the user, its validation errors are cleared so that they aren't rendered. -Handling “Cannot dehydrate an unpersisted entity” Errors. +Handling "Cannot dehydrate an unpersisted entity" Errors. ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ If you're building a form to create a *new* entity, then when you render the component, you may be passing in a new, non-persisted entity. For example, imagine you create a ``new Post()`` in your controller, -pass this “not-yet-persisted” entity into your template as a ``post`` +pass this "not-yet-persisted" entity into your template as a ``post`` variable and pass *that* into your component: .. code-block:: twig @@ -906,8 +891,8 @@ If you do this, you'll likely see this error: The problem is that the Live component system doesn't know how to transform this object into something that can be sent to the frontend, -called “dehydration”. If an entity has already been saved to the -database, its “id” is sent to the frontend. But if the entity hasn't +called "dehydration". If an entity has already been saved to the +database, its "id" is sent to the frontend. But if the entity hasn't been saved yet, that's not possible. The solution is to pass ``null`` into your component instead of a @@ -933,10 +918,10 @@ behave how you want. If you're re-rendering a field on the ``input`` event (that's the default event on a field, which is fired each time you type in a text -box), then if you type a “space” and pause for a moment, the space will +box), then if you type a "space" and pause for a moment, the space will disappear! -This is because Symfony text fields “trim spaces” automatically. When +This is because Symfony text fields "trim spaces" automatically. When your component re-renders, the space will disappear… as the user is typing! To fix this, either re-render on the ``change`` event (which fires after the text box loses focus) or set the ``trim`` option of your @@ -1070,6 +1055,152 @@ section above) is to add: + data-action="change->live#update" > +Using Actions to Change your Form: CollectionType +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Symfony's `CollectionType`_ can be used to embed a collection of +embedded forms including allowing the user to dynamically add or remove +them. Live components can accomplish make this all possible while +writing zero JavaScript. + +For example, imagine a "Blog Post" form with an embedded "Comment" forms +via the ``CollectionType``:: + + namespace App\Form; + + use Symfony\Component\Form\AbstractType; + use Symfony\Component\Form\Extension\Core\Type\CollectionType; + use Symfony\Component\Form\FormBuilderInterface; + use Symfony\Component\OptionsResolver\OptionsResolver; + use App\Entity\BlogPost; + + class BlogPostFormType extends AbstractType + { + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder + ->add('title', TextType::class) + // ... + ->add('comments', CollectionType::class, [ + 'entry_type' => CommentFormType::class, + 'allow_add' => true, + 'allow_delete' => true, + 'by_reference' => false, + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults(['data_class' => BlogPost::class]); + } + } + +Now, create a Twig component to render the form:: + + namespace App\Twig; + + use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; + use Symfony\Component\Form\FormInterface; + use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; + use Symfony\UX\LiveComponent\Attribute\LiveAction; + use Symfony\UX\LiveComponent\ComponentWithFormTrait; + use Symfony\UX\LiveComponent\DefaultActionTrait; + use App\Entity\BlogPost; + use App\Entity\Comment; + use App\Form\BlogPostFormType; + + #[AsLiveComponent('blog_post_collection_type')] + class BlogPostCollectionTypeComponent extends AbstractController + { + use ComponentWithFormTrait; + use DefaultActionTrait; + + #[LiveProp] + public BlogPost $post; + + protected function instantiateForm(): FormInterface + { + return $this->createForm(BlogPostFormType::class, $this->post); + } + + #[LiveAction] + public function addComment() + { + // "formValues" represents the current data in the form + // this modifies the form to add an extra comment + // the result: another embedded comment form! + // change "comments" to the name of the field that uses CollectionType + $this->formValues['comments'][] = []; + } + + #[LiveAction] + public function removeComment(#[LiveArg] int $index) + { + unset($this->formValues['comments'][$index]); + } + } + +The template for this component has two jobs: (1) render the form +like normal and (2) include links that trigger the ``addComment()`` +and ``removeComment()`` actions: + +.. code-block:: twig + + + {{ form_start(this.form) }} + {{ form_row(this.form.title) }} + +

Comments:

+ {% for key, commentForm in this.form.comments %} + + + {{ form_widget(commentForm) }} + {% endfor %} +
+ + {# avoid an extra label for this field #} + {% do this.form.comments.setRendered %} + + + + + {{ form_end(this.form) }} + + +Done! Behind the scenes, it works like this: + +A) When the user clicks "+ Add Comment", an Ajax request is sent that +triggers the ``addComment()`` action. + +B) ``addComment()`` modifies ``formValues``, which you can think of as +the raw "POST" data of your form. + +C) Still during the Ajax request, the ``formValues`` are "submitted" +into your form. The new key inside of ``$this->formValues['comments']`` +tells the ``CollectionType`` that you want a new, embedded form. + +D) The form is rendered - now with another embedded form! - and the +Ajax call returns with the form (with the new embedded form). + +When the user clicks ``removeComment()``, a similar process happens. + +.. note:: + + When working with Doctrine entities, add ``orphanRemoval: true`` + and ``cascade={"persist"}`` to your ``OneToMany`` relationship. + In this example, these options would be added to the ``OneToMany`` + attribute above the ``Post.comments`` property. These help new + items save and deletes any items whose embedded forms are removed. + Modifying Embedded Properties with the "exposed" Option ------------------------------------------------------- @@ -1091,7 +1222,7 @@ edited:: public Post $post; } -In the template, let's render an HTML form *and* a “preview” area where +In the template, let's render an HTML form *and* a "preview" area where the user can see, as they type, what the post will look like (including rendered the ``content`` through a Markdown filter from the ``twig/markdown-extra`` library): @@ -1187,7 +1318,7 @@ where you want the object on that property to also be validated. Thanks to this setup, the component will now be automatically validated on each render, but in a smart way: a property will only be validated -once its “model” has been updated on the frontend. The system keeps +once its "model" has been updated on the frontend. The system keeps track of which models have been updated (e.g. ``data-action="live#update"``) and only stores the errors for those fields on re-render. @@ -1229,7 +1360,7 @@ method: class="{{ this.getError('post.content') ? 'has-error' : '' }}" >{{ post.content }} -Once a component has been validated, the component will “rememeber” that +Once a component has been validated, the component will "rememeber" that it has been validated. This means that, if you edit a field and the component re-renders, it will be validated again. @@ -1238,7 +1369,7 @@ Real Time Validation As soon as you enable validation, each field will automatically be validated when its model is updated. For example, if you want a single -field to be validated “on change” (when you change the field and then +field to be validated "on change" (when you change the field and then blur the field), update the model via the ``change`` event: .. code-block:: twig @@ -1251,13 +1382,13 @@ blur the field), update the model via the ``change`` event: When the component re-renders, it will signal to the server that this one field should be validated. Like with normal validation, once an -individual field has been validated, the component “remembers” that, and +individual field has been validated, the component "remembers" that, and re-validates it on each render. Polling ------- -You can also use “polling” to continually refresh a component. On the +You can also use "polling" to continually refresh a component. On the **top-level** element for your component, add ``data-poll``: .. code-block:: diff @@ -1279,7 +1410,7 @@ delay for 500ms: data-poll="delay(500)|$render" > -You can also trigger a specific “action” instead of a normal re-render: +You can also trigger a specific "action" instead of a normal re-render: .. code-block:: twig @@ -1313,7 +1444,7 @@ component is its own, isolated universe. But this is not always what you want. For example, suppose you have a parent component that renders a form and a child component that renders -one field in that form. When you click a “Save” button on the parent +one field in that form. When you click a "Save" button on the parent component, that validates the form and re-renders with errors - including a new ``error`` value that it passes into the child: @@ -1376,7 +1507,7 @@ event is dispatched. All components automatically listen to this event. This means that, when the ``markdown_value`` model is updated in the child component, *if* the parent component *also* has a model called ``markdown_value`` it will *also* be updated. This is done as a -“deferred” update +"deferred" update (i.e. :ref:`updateDefer() `). If the model name in your child component (e.g. ``markdown_value``) is @@ -1519,6 +1650,20 @@ form. But it also makes sure that when the ``textarea`` changes, both the ``value`` model in ``MarkdownTextareaComponent`` *and* the ``post.content`` model in ``EditPostcomponent`` will be updated. +Skipping Updating Certain Elements +---------------------------------- + +Sometimes you may have an element inside a component that you do *not* want to +change whenever your component re-renders. For example, some elements managed by +third-party JavaScript or a form element that is not bound to a model... where you +don't want a re-render to reset the data the user has entered. + +To handle this, add the ``data-live-ignore`` attribute to the element: + +.. code-block:: html + + + Backward Compatibility promise ------------------------------ @@ -1539,3 +1684,4 @@ bound to Symfony's BC policy for the moment. .. _`dependent form fields`: https://symfony.com/doc/current/form/dynamic_form_modification.html#dynamic-generation-for-submitted-forms .. _`Symfony UX configured in your app`: https://symfony.com/doc/current/frontend/ux.html .. _`Component attributes`: https://symfony.com/bundles/ux-twig-component/current/index.html#component-attributes +.. _`CollectionType`: https://symfony.com/doc/current/form/form_collections.html diff --git a/src/LiveComponent/src/Twig/LiveComponentRuntime.php b/src/LiveComponent/src/Twig/LiveComponentRuntime.php index 65856865719..d843da6c2a5 100644 --- a/src/LiveComponent/src/Twig/LiveComponentRuntime.php +++ b/src/LiveComponent/src/Twig/LiveComponentRuntime.php @@ -12,12 +12,8 @@ namespace Symfony\UX\LiveComponent\Twig; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; -use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; use Symfony\UX\LiveComponent\LiveComponentHydrator; -use Symfony\UX\TwigComponent\ComponentAttributes; use Symfony\UX\TwigComponent\ComponentFactory; -use Symfony\UX\TwigComponent\ComponentMetadata; -use Twig\Environment; /** * @author Kevin Bond @@ -27,37 +23,17 @@ final class LiveComponentRuntime { public function __construct( - private Environment $twig, private LiveComponentHydrator $hydrator, private ComponentFactory $factory, private UrlGeneratorInterface $urlGenerator, - private ?CsrfTokenManagerInterface $csrfTokenManager = null ) { } public function getComponentUrl(string $name, array $props = []): string { - $component = $this->factory->create($name, $props); - $params = ['component' => $name] + $this->hydrator->dehydrate($component); + $mounted = $this->factory->create($name, $props); + $params = ['component' => $name] + $this->hydrator->dehydrate($mounted); return $this->urlGenerator->generate('live_component', $params); } - - public function getLiveAttributes(object $component, ComponentMetadata $metadata): ComponentAttributes - { - $url = $this->urlGenerator->generate('live_component', ['component' => $metadata->getName()]); - $data = $this->hydrator->dehydrate($component); - - $attributes = [ - 'data-controller' => 'live', - 'data-live-url-value' => twig_escape_filter($this->twig, $url, 'html_attr'), - 'data-live-data-value' => twig_escape_filter($this->twig, json_encode($data, \JSON_THROW_ON_ERROR), 'html_attr'), - ]; - - if ($this->csrfTokenManager) { - $attributes['data-live-csrf-value'] = $this->csrfTokenManager->getToken($metadata->getName())->getValue(); - } - - return new ComponentAttributes($attributes); - } } diff --git a/src/LiveComponent/src/Util/LiveFormUtility.php b/src/LiveComponent/src/Util/LiveFormUtility.php new file mode 100644 index 00000000000..085f090bfc9 --- /dev/null +++ b/src/LiveComponent/src/Util/LiveFormUtility.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Util; + +use Symfony\Component\Form\FormView; + +final class LiveFormUtility +{ + /** + * Removes the "paths" not present in the $data array. + * + * Given an array of paths - ['name', 'post.title', 'post.description'] - + * and a $data array - ['name' => 'Ryan', 'post' => ['title' => 'Hi there!']] - + * this removes any "paths" not present in the array. + */ + public static function removePathsNotInData(array $paths, array $data): array + { + return array_values(array_filter($paths, static function ($path) use ($data) { + $parts = explode('.', $path); + while (\count($parts) > 0) { + $part = $parts[0]; + if (!\array_key_exists($part, $data)) { + return false; + } + + // reset $parts and go to the next level + unset($parts[0]); + $parts = array_values($parts); + $data = $data[$part]; + } + + // key was found at all levels + return true; + })); + } + + public static function doesFormContainAnyErrors(FormView $formView): bool + { + if (($formView->vars['errors'] ?? null) && \count($formView->vars['errors']) > 0) { + return true; + } + + foreach ($formView->children as $childView) { + if (self::doesFormContainAnyErrors($childView)) { + return true; + } + } + + return false; + } +} diff --git a/src/LiveComponent/tests/Fixture/Component/Component1.php b/src/LiveComponent/tests/Fixtures/Component/Component1.php similarity index 87% rename from src/LiveComponent/tests/Fixture/Component/Component1.php rename to src/LiveComponent/tests/Fixtures/Component/Component1.php index f1f7a255bca..dbf40a126a7 100644 --- a/src/LiveComponent/tests/Fixture/Component/Component1.php +++ b/src/LiveComponent/tests/Fixtures/Component/Component1.php @@ -9,13 +9,13 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\LiveComponent\Tests\Fixture\Component; +namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component; use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\Attribute\LiveAction; use Symfony\UX\LiveComponent\Attribute\LiveProp; use Symfony\UX\LiveComponent\DefaultActionTrait; -use Symfony\UX\LiveComponent\Tests\Fixture\Entity\Entity1; +use Symfony\UX\LiveComponent\Tests\Fixtures\Entity\Entity1; /** * @author Kevin Bond diff --git a/src/LiveComponent/tests/Fixture/Component/Component2.php b/src/LiveComponent/tests/Fixtures/Component/Component2.php similarity index 96% rename from src/LiveComponent/tests/Fixture/Component/Component2.php rename to src/LiveComponent/tests/Fixtures/Component/Component2.php index a52c060dedd..51ca3415a40 100644 --- a/src/LiveComponent/tests/Fixture/Component/Component2.php +++ b/src/LiveComponent/tests/Fixtures/Component/Component2.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\LiveComponent\Tests\Fixture\Component; +namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; diff --git a/src/LiveComponent/tests/Fixture/Component/Component3.php b/src/LiveComponent/tests/Fixtures/Component/Component3.php similarity index 92% rename from src/LiveComponent/tests/Fixture/Component/Component3.php rename to src/LiveComponent/tests/Fixtures/Component/Component3.php index 983dc03eee3..f038e0d6b64 100644 --- a/src/LiveComponent/tests/Fixture/Component/Component3.php +++ b/src/LiveComponent/tests/Fixtures/Component/Component3.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\LiveComponent\Tests\Fixture\Component; +namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component; use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\Attribute\LiveProp; diff --git a/src/LiveComponent/tests/Fixture/Component/Component4.php b/src/LiveComponent/tests/Fixtures/Component/Component4.php similarity index 94% rename from src/LiveComponent/tests/Fixture/Component/Component4.php rename to src/LiveComponent/tests/Fixtures/Component/Component4.php index 0461b69c9b6..71719ba6304 100644 --- a/src/LiveComponent/tests/Fixture/Component/Component4.php +++ b/src/LiveComponent/tests/Fixtures/Component/Component4.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\LiveComponent\Tests\Fixture\Component; +namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component; use Symfony\UX\LiveComponent\Attribute\BeforeReRender; use Symfony\UX\LiveComponent\Attribute\LiveAction; diff --git a/src/LiveComponent/tests/Fixture/Component/Component5.php b/src/LiveComponent/tests/Fixtures/Component/Component5.php similarity index 89% rename from src/LiveComponent/tests/Fixture/Component/Component5.php rename to src/LiveComponent/tests/Fixtures/Component/Component5.php index 43703f1b359..db48bf86f89 100644 --- a/src/LiveComponent/tests/Fixture/Component/Component5.php +++ b/src/LiveComponent/tests/Fixtures/Component/Component5.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\LiveComponent\Tests\Fixture\Component; +namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component; use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\DefaultActionTrait; diff --git a/src/LiveComponent/tests/Fixture/Component/Component6.php b/src/LiveComponent/tests/Fixtures/Component/Component6.php similarity index 94% rename from src/LiveComponent/tests/Fixture/Component/Component6.php rename to src/LiveComponent/tests/Fixtures/Component/Component6.php index e1d1bcc529f..1d928135be6 100644 --- a/src/LiveComponent/tests/Fixture/Component/Component6.php +++ b/src/LiveComponent/tests/Fixtures/Component/Component6.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\LiveComponent\Tests\Fixture\Component; +namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component; use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\Attribute\LiveAction; diff --git a/src/LiveComponent/tests/Fixture/Component/ComponentWithAttributes.php b/src/LiveComponent/tests/Fixtures/Component/ComponentWithAttributes.php similarity index 66% rename from src/LiveComponent/tests/Fixture/Component/ComponentWithAttributes.php rename to src/LiveComponent/tests/Fixtures/Component/ComponentWithAttributes.php index 5f79b50a9f0..535b785e6ab 100644 --- a/src/LiveComponent/tests/Fixture/Component/ComponentWithAttributes.php +++ b/src/LiveComponent/tests/Fixtures/Component/ComponentWithAttributes.php @@ -1,10 +1,9 @@ @@ -13,5 +12,4 @@ final class ComponentWithAttributes { use DefaultActionTrait; - use HasAttributesTrait; } diff --git a/src/LiveComponent/tests/Fixtures/Component/FormWithCollectionTypeComponent.php b/src/LiveComponent/tests/Fixtures/Component/FormWithCollectionTypeComponent.php new file mode 100644 index 00000000000..1686b9a9987 --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/Component/FormWithCollectionTypeComponent.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component; + +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\Form\FormInterface; +use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; +use Symfony\UX\LiveComponent\Attribute\LiveAction; +use Symfony\UX\LiveComponent\Attribute\LiveArg; +use Symfony\UX\LiveComponent\ComponentWithFormTrait; +use Symfony\UX\LiveComponent\DefaultActionTrait; +use Symfony\UX\LiveComponent\Tests\Fixtures\Dto\BlogPost; +use Symfony\UX\LiveComponent\Tests\Fixtures\Dto\Comment; +use Symfony\UX\LiveComponent\Tests\Fixtures\Form\BlogPostFormType; + +#[AsLiveComponent('form_with_collection_type')] +class FormWithCollectionTypeComponent extends AbstractController +{ + use ComponentWithFormTrait; + use DefaultActionTrait; + + public BlogPost $post; + + public function __construct() + { + $this->post = new BlogPost(); + // start with 1 comment + $this->post->comments[] = new Comment(); + } + + protected function instantiateForm(): FormInterface + { + return $this->createForm(BlogPostFormType::class, $this->post); + } + + #[LiveAction] + public function addComment() + { + $this->formValues['comments'][] = []; + } + + #[LiveAction] + public function removeComment(#[LiveArg] int $index) + { + unset($this->formValues['comments'][$index]); + } +} diff --git a/src/LiveComponent/tests/Fixtures/Dto/BlogPost.php b/src/LiveComponent/tests/Fixtures/Dto/BlogPost.php new file mode 100644 index 00000000000..b57f2cfe721 --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/Dto/BlogPost.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Tests\Fixtures\Dto; + +use Symfony\Component\Validator\Constraints\Length; +use Symfony\Component\Validator\Constraints\NotBlank; + +class BlogPost +{ + #[NotBlank(message: 'The title field should not be blank')] + public $title; + + #[Length(min: 100, minMessage: 'The content field is too short')] + public $content; + + public $comments = []; +} diff --git a/src/LiveComponent/tests/Fixtures/Dto/Comment.php b/src/LiveComponent/tests/Fixtures/Dto/Comment.php new file mode 100644 index 00000000000..37b245a96bc --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/Dto/Comment.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Tests\Fixtures\Dto; + +class Comment +{ + public ?string $content; + + public ?BlogPost $blogPost; +} diff --git a/src/LiveComponent/tests/Fixture/Entity/Entity1.php b/src/LiveComponent/tests/Fixtures/Entity/Entity1.php similarity index 87% rename from src/LiveComponent/tests/Fixture/Entity/Entity1.php rename to src/LiveComponent/tests/Fixtures/Entity/Entity1.php index 48f63093302..4187ad5c486 100644 --- a/src/LiveComponent/tests/Fixture/Entity/Entity1.php +++ b/src/LiveComponent/tests/Fixtures/Entity/Entity1.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\LiveComponent\Tests\Fixture\Entity; +namespace Symfony\UX\LiveComponent\Tests\Fixtures\Entity; use Doctrine\ORM\Mapping as ORM; diff --git a/src/LiveComponent/tests/Fixtures/Form/BlogPostFormType.php b/src/LiveComponent/tests/Fixtures/Form/BlogPostFormType.php new file mode 100644 index 00000000000..4c302871c32 --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/Form/BlogPostFormType.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Symfony\UX\LiveComponent\Tests\Fixtures\Form; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\CollectionType; +use Symfony\Component\Form\Extension\Core\Type\TextareaType; +use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\UX\LiveComponent\Tests\Fixtures\Dto\BlogPost; + +class BlogPostFormType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder + ->add('title', TextType::class) + ->add('content', TextareaType::class) + ->add('comments', CollectionType::class, [ + 'entry_type' => CommentFormType::class, + 'allow_add' => true, + 'allow_delete' => true, + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'csrf_protection' => false, + 'data_class' => BlogPost::class, + ]); + } +} diff --git a/src/LiveComponent/tests/Fixtures/Form/CommentFormType.php b/src/LiveComponent/tests/Fixtures/Form/CommentFormType.php new file mode 100644 index 00000000000..c836fef70b1 --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/Form/CommentFormType.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Symfony\UX\LiveComponent\Tests\Fixtures\Form; + +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\TextareaType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\UX\LiveComponent\Tests\Fixtures\Dto\Comment; + +class CommentFormType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options) + { + $builder + ->add('content', TextareaType::class) + ; + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'csrf_protection' => false, + 'data_class' => Comment::class, + ]); + } +} diff --git a/src/LiveComponent/tests/Fixture/Kernel.php b/src/LiveComponent/tests/Fixtures/Kernel.php similarity index 84% rename from src/LiveComponent/tests/Fixture/Kernel.php rename to src/LiveComponent/tests/Fixtures/Kernel.php index 14b5e059efc..cc1be180a41 100644 --- a/src/LiveComponent/tests/Fixture/Kernel.php +++ b/src/LiveComponent/tests/Fixtures/Kernel.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\LiveComponent\Tests\Fixture; +namespace Symfony\UX\LiveComponent\Tests\Fixtures; use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; use Psr\Log\NullLogger; @@ -22,11 +22,12 @@ use Symfony\Component\HttpKernel\Kernel as BaseKernel; use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; use Symfony\UX\LiveComponent\LiveComponentBundle; -use Symfony\UX\LiveComponent\Tests\Fixture\Component\Component1; -use Symfony\UX\LiveComponent\Tests\Fixture\Component\Component2; -use Symfony\UX\LiveComponent\Tests\Fixture\Component\Component3; -use Symfony\UX\LiveComponent\Tests\Fixture\Component\Component6; -use Symfony\UX\LiveComponent\Tests\Fixture\Component\ComponentWithAttributes; +use Symfony\UX\LiveComponent\Tests\Fixtures\Component\Component1; +use Symfony\UX\LiveComponent\Tests\Fixtures\Component\Component2; +use Symfony\UX\LiveComponent\Tests\Fixtures\Component\Component3; +use Symfony\UX\LiveComponent\Tests\Fixtures\Component\Component6; +use Symfony\UX\LiveComponent\Tests\Fixtures\Component\ComponentWithAttributes; +use Symfony\UX\LiveComponent\Tests\Fixtures\Component\FormWithCollectionTypeComponent; use Symfony\UX\TwigComponent\TwigComponentBundle; use Twig\Environment; @@ -68,6 +69,7 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load $c->register(Component3::class)->setAutoconfigured(true)->setAutowired(true); $c->register(Component6::class)->setAutoconfigured(true)->setAutowired(true); $c->register(ComponentWithAttributes::class)->setAutoconfigured(true)->setAutowired(true); + $c->register(FormWithCollectionTypeComponent::class)->setAutoconfigured(true)->setAutowired(true); $c->loadFromExtension('framework', [ 'secret' => 'S3CRET', @@ -78,7 +80,7 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load ]); $c->loadFromExtension('twig', [ - 'default_path' => '%kernel.project_dir%/tests/Fixture/templates', + 'default_path' => '%kernel.project_dir%/tests/Fixtures/templates', ]); $c->loadFromExtension('doctrine', [ @@ -90,8 +92,8 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load 'Test' => [ 'is_bundle' => false, 'type' => 'annotation', - 'dir' => '%kernel.project_dir%/tests/Fixture/Entity', - 'prefix' => 'Symfony\UX\LiveComponent\Tests\Fixture\Entity', + 'dir' => '%kernel.project_dir%/tests/Fixtures/Entity', + 'prefix' => 'Symfony\UX\LiveComponent\Tests\Fixtures\Entity', 'alias' => 'Test', ], ], diff --git a/src/LiveComponent/tests/Fixture/templates/component_url.html.twig b/src/LiveComponent/tests/Fixtures/templates/component_url.html.twig similarity index 100% rename from src/LiveComponent/tests/Fixture/templates/component_url.html.twig rename to src/LiveComponent/tests/Fixtures/templates/component_url.html.twig diff --git a/src/LiveComponent/tests/Fixture/templates/components/component1.html.twig b/src/LiveComponent/tests/Fixtures/templates/components/component1.html.twig similarity index 100% rename from src/LiveComponent/tests/Fixture/templates/components/component1.html.twig rename to src/LiveComponent/tests/Fixtures/templates/components/component1.html.twig diff --git a/src/LiveComponent/tests/Fixture/templates/components/component2.html.twig b/src/LiveComponent/tests/Fixtures/templates/components/component2.html.twig similarity index 100% rename from src/LiveComponent/tests/Fixture/templates/components/component2.html.twig rename to src/LiveComponent/tests/Fixtures/templates/components/component2.html.twig diff --git a/src/LiveComponent/tests/Fixture/templates/components/component6.html.twig b/src/LiveComponent/tests/Fixtures/templates/components/component6.html.twig similarity index 100% rename from src/LiveComponent/tests/Fixture/templates/components/component6.html.twig rename to src/LiveComponent/tests/Fixtures/templates/components/component6.html.twig diff --git a/src/LiveComponent/tests/Fixtures/templates/components/form_with_collection_type.html.twig b/src/LiveComponent/tests/Fixtures/templates/components/form_with_collection_type.html.twig new file mode 100644 index 00000000000..56334201bc5 --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/templates/components/form_with_collection_type.html.twig @@ -0,0 +1,3 @@ + + {{ form(this.form) }} + diff --git a/src/LiveComponent/tests/Fixture/templates/template1.html.twig b/src/LiveComponent/tests/Fixtures/templates/template1.html.twig similarity index 100% rename from src/LiveComponent/tests/Fixture/templates/template1.html.twig rename to src/LiveComponent/tests/Fixtures/templates/template1.html.twig diff --git a/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php b/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php index 80ef23e3892..6225d823c20 100644 --- a/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php +++ b/src/LiveComponent/tests/Functional/EventListener/LiveComponentSubscriberTest.php @@ -14,10 +14,7 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\UX\LiveComponent\LiveComponentHydrator; -use Symfony\UX\LiveComponent\Tests\Fixture\Component\Component1; -use Symfony\UX\LiveComponent\Tests\Fixture\Component\Component2; -use Symfony\UX\LiveComponent\Tests\Fixture\Component\Component6; -use Symfony\UX\LiveComponent\Tests\Fixture\Entity\Entity1; +use Symfony\UX\LiveComponent\Tests\Fixtures\Entity\Entity1; use Symfony\UX\TwigComponent\ComponentFactory; use Zenstruck\Browser\Response\HtmlResponse; use Zenstruck\Browser\Test\HasBrowser; @@ -42,7 +39,6 @@ public function testCanRenderComponentAsHtml(): void /** @var ComponentFactory $factory */ $factory = self::getContainer()->get('ux.twig_component.component_factory'); - /** @var Component1 $component */ $component = $factory->create('component1', [ 'prop1' => $entity = create(Entity1::class)->object(), 'prop2' => $date = new \DateTime('2021-03-05 9:23'), @@ -54,7 +50,7 @@ public function testCanRenderComponentAsHtml(): void $this->browser() ->throwExceptions() - ->get('/_components/component1?'.http_build_query($dehydrated)) + ->get('/_components/component1?data='.urlencode(json_encode($dehydrated))) ->assertSuccessful() ->assertHeaderContains('Content-Type', 'html') ->assertContains('Prop1: '.$entity->id) @@ -72,15 +68,12 @@ public function testCanExecuteComponentAction(): void /** @var ComponentFactory $factory */ $factory = self::getContainer()->get('ux.twig_component.component_factory'); - /** @var Component2 $component */ - $component = $factory->create('component2'); - - $dehydrated = $hydrator->dehydrate($component); + $dehydrated = $hydrator->dehydrate($factory->create('component2')); $token = null; $this->browser() ->throwExceptions() - ->get('/_components/component2?'.http_build_query($dehydrated)) + ->get('/_components/component2?data='.urlencode(json_encode($dehydrated))) ->assertSuccessful() ->assertHeaderContains('Content-Type', 'html') ->assertContains('Count: 1') @@ -88,8 +81,9 @@ public function testCanExecuteComponentAction(): void // get a valid token to use for actions $token = $response->crawler()->filter('div')->first()->attr('data-live-csrf-value'); }) - ->post('/_components/component2/increase?'.http_build_query($dehydrated), [ + ->post('/_components/component2/increase', [ 'headers' => ['X-CSRF-TOKEN' => $token], + 'body' => json_encode($dehydrated), ]) ->assertSuccessful() ->assertHeaderContains('Content-Type', 'html') @@ -159,16 +153,13 @@ public function testBeforeReRenderHookOnlyExecutedDuringAjax(): void /** @var ComponentFactory $factory */ $factory = self::getContainer()->get('ux.twig_component.component_factory'); - /** @var Component2 $component */ - $component = $factory->create('component2'); - - $dehydrated = $hydrator->dehydrate($component); + $dehydrated = $hydrator->dehydrate($factory->create('component2')); $this->browser() ->visit('/render-template/template1') ->assertSuccessful() ->assertSee('BeforeReRenderCalled: No') - ->get('/_components/component2?'.http_build_query($dehydrated)) + ->get('/_components/component2?data='.urlencode(json_encode($dehydrated))) ->assertSuccessful() ->assertSee('BeforeReRenderCalled: Yes') ; @@ -182,15 +173,12 @@ public function testCanRedirectFromComponentAction(): void /** @var ComponentFactory $factory */ $factory = self::getContainer()->get('ux.twig_component.component_factory'); - /** @var Component2 $component */ - $component = $factory->create('component2'); - - $dehydrated = $hydrator->dehydrate($component); + $dehydrated = $hydrator->dehydrate($factory->create('component2')); $token = null; $this->browser() ->throwExceptions() - ->get('/_components/component2?'.http_build_query($dehydrated)) + ->get('/_components/component2?data='.urlencode(json_encode($dehydrated))) ->assertSuccessful() ->use(function (HtmlResponse $response) use (&$token) { // get a valid token to use for actions @@ -198,17 +186,19 @@ public function testCanRedirectFromComponentAction(): void }) ->interceptRedirects() // with no custom header, it redirects like a normal browser - ->post('/_components/component2/redirect?'.http_build_query($dehydrated), [ + ->post('/_components/component2/redirect', [ 'headers' => ['X-CSRF-TOKEN' => $token], + 'body' => json_encode($dehydrated), ]) ->assertRedirectedTo('/') // with custom header, a special 204 is returned - ->post('/_components/component2/redirect?'.http_build_query($dehydrated), [ + ->post('/_components/component2/redirect', [ 'headers' => [ 'Accept' => 'application/vnd.live-component+html', 'X-CSRF-TOKEN' => $token, ], + 'body' => json_encode($dehydrated), ]) ->assertStatus(204) ->assertHeaderEquals('Location', '/') @@ -223,19 +213,13 @@ public function testInjectsLiveArgs(): void /** @var ComponentFactory $factory */ $factory = self::getContainer()->get('ux.twig_component.component_factory'); - /** @var Component6 $component */ - $component = $factory->create('component6'); - - $dehydrated = $hydrator->dehydrate($component); + $dehydrated = $hydrator->dehydrate($factory->create('component6')); $token = null; - $dehydratedWithArgs = array_merge($dehydrated, [ - 'args' => http_build_query(['arg1' => 'hello', 'arg2' => 666, 'custom' => '33.3']), - ]); - + $argsQueryParams = http_build_query(['args' => http_build_query(['arg1' => 'hello', 'arg2' => 666, 'custom' => '33.3'])]); $this->browser() ->throwExceptions() - ->get('/_components/component6?'.http_build_query($dehydrated)) + ->get('/_components/component6?data='.urlencode(json_encode($dehydrated)).'&'.$argsQueryParams) ->assertSuccessful() ->assertHeaderContains('Content-Type', 'html') ->assertContains('Arg1: not provided') @@ -245,8 +229,9 @@ public function testInjectsLiveArgs(): void // get a valid token to use for actions $token = $response->crawler()->filter('div')->first()->attr('data-live-csrf-value'); }) - ->post('/_components/component6/inject?'.http_build_query($dehydratedWithArgs), [ + ->post('/_components/component6/inject?'.$argsQueryParams, [ 'headers' => ['X-CSRF-TOKEN' => $token], + 'body' => json_encode($dehydrated), ]) ->assertSuccessful() ->assertHeaderContains('Content-Type', 'html') diff --git a/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php b/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php new file mode 100644 index 00000000000..7be4c894edf --- /dev/null +++ b/src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php @@ -0,0 +1,149 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Symfony\UX\LiveComponent\Tests\Functional\EventListener; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\DomCrawler\Crawler; +use Symfony\Component\Form\FormFactoryInterface; +use Symfony\UX\LiveComponent\LiveComponentHydrator; +use Symfony\UX\LiveComponent\Tests\Fixtures\Component\FormWithCollectionTypeComponent; +use Symfony\UX\LiveComponent\Tests\Fixtures\Form\BlogPostFormType; +use Symfony\UX\TwigComponent\ComponentFactory; +use Zenstruck\Browser\Response\HtmlResponse; +use Zenstruck\Browser\Test\HasBrowser; +use Zenstruck\Foundry\Test\Factories; +use Zenstruck\Foundry\Test\ResetDatabase; + +/** + * @author Jakub Caban + */ +class ComponentWithFormTest extends KernelTestCase +{ + use Factories; + use HasBrowser; + use ResetDatabase; + + public function testFormValuesRebuildAfterFormChanges(): void + { + /** @var LiveComponentHydrator $hydrator */ + $hydrator = self::getContainer()->get('ux.live_component.component_hydrator'); + /** @var ComponentFactory $factory */ + $factory = self::getContainer()->get('ux.twig_component.component_factory'); + $component = $factory->create('form_with_collection_type'); + + $dehydrated = $hydrator->dehydrate($component); + $token = null; + + $this->browser() + ->get('/_components/form_with_collection_type?data='.urlencode(json_encode($dehydrated))) + ->use(function (HtmlResponse $response) use (&$dehydrated, &$token) { + // mimic user typing + $dehydrated['blog_post_form']['content'] = 'changed description by user'; + $dehydrated['validatedFields'] = ['blog_post_form.content']; + $token = $response->crawler()->filter('div')->first()->attr('data-live-csrf-value'); + }) + + // post to action, which will add a new embedded comment + ->post('/_components/form_with_collection_type/addComment', [ + 'body' => json_encode($dehydrated), + 'headers' => ['X-CSRF-TOKEN' => $token], + ]) + ->assertStatus(422) + // look for original embedded form + ->assertContains('') + // check that validation happened and stuck + ->assertContains('The content field is too short') + // make sure the title field did not suddenly become validated + ->assertNotContains('The title field should not be blank') + ->use(function (Crawler $crawler) use (&$dehydrated, &$token) { + $div = $crawler->filter('[data-controller="live"]'); + $liveData = json_decode($div->attr('data-live-data-value'), true); + // make sure the 2nd collection type was initialized, that it didn't + // just "keep" the empty array that we set it to in the component + $this->assertEquals( + [ + ['content' => ''], + ['content' => ''], + ], + $liveData['blog_post_form']['comments'] + ); + + // grab the latest live data + $dehydrated = $liveData; + // fake that this field was being validated + $dehydrated['validatedFields'][] = 'blog_post_form.0.comments.content'; + $token = $div->attr('data-live-csrf-value'); + }) + + // post to action, which will remove the original embedded comment + ->post('/_components/form_with_collection_type/removeComment?'.http_build_query(['args' => 'index=0']), [ + 'body' => json_encode($dehydrated), + 'headers' => ['X-CSRF-TOKEN' => $token], + ]) + ->assertStatus(422) + // the original embedded form should be gone + ->assertNotContains('