Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 19 additions & 19 deletions src/Autocomplete/assets/dist/controller.js
Original file line number Diff line number Diff line change
@@ -1,25 +1,25 @@
import { Controller } from '@hotwired/stimulus';
import TomSelect from 'tom-select';

/******************************************************************************
Copyright (c) Microsoft Corporation.

Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.

THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.
***************************************************************************** */

function __classPrivateFieldGet(receiver, state, kind, f) {
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
/******************************************************************************
Copyright (c) Microsoft Corporation.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.
***************************************************************************** */
function __classPrivateFieldGet(receiver, state, kind, f) {
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
}

var _instances, _getCommonConfig, _createAutocomplete, _createAutocompleteWithHtmlContents, _createAutocompleteWithRemoteData, _stripTags, _mergeObjects, _createTomSelect, _dispatchEvent;
Expand Down
8 changes: 8 additions & 0 deletions src/Collection/.gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/.gitattributes export-ignore
/.gitignore export-ignore
/.symfony.bundle.yaml export-ignore
/phpunit.xml.dist export-ignore
/phpstan.neon.dist export-ignore
/Resources/assets/test export-ignore
/Resources/assets/jest.config.js export-ignore
/Tests export-ignore
12 changes: 12 additions & 0 deletions src/Collection/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/.php_cs.cache
/.php_cs
/.phpunit.result.cache
/composer.phar
/composer.lock
/phpunit.xml
/vendor/
/Tests/app/var
/Tests/app/public/build/
node_modules/
package-lock.json
yarn.lock
3 changes: 3 additions & 0 deletions src/Collection/.symfony.bundle.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
branches: ["2.x"]
maintained_branches: ["2.x"]
doc_dir: "Resources/doc"
16 changes: 16 additions & 0 deletions src/Collection/CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Contributing

Install the test app:

$ composer install
$ cd Tests/app
$ yarn install
$ yarn build

Start the test app:

$ symfony serve

## Run tests

$ php vendor/bin/simple-phpunit
21 changes: 21 additions & 0 deletions src/Collection/CollectionBundle.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\UX\Collection;

use Symfony\Component\HttpKernel\Bundle\Bundle;

/**
* @author Kévin Dunglas <[email protected]>
*/
final class CollectionBundle extends Bundle
{
}
19 changes: 19 additions & 0 deletions src/Collection/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
Copyright (c) 2021 Kévin Dunglas
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2022? 😌


Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
11 changes: 11 additions & 0 deletions src/Collection/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Symfony UX Collection

**This repository is a READ-ONLY sub-tree split**. See
https://github.com/symfony/ux to create issues or submit pull requests.

## Resources

- [Documentation](https://symfony.com/bundles/ux-collection/current/index.html)
- [Report issues](https://github.com/symfony/ux/issues) and
[send Pull Requests](https://github.com/symfony/ux/pulls)
in the [main Symfony UX repository](https://github.com/symfony/ux)
87 changes: 87 additions & 0 deletions src/Collection/Resources/assets/dist/controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { Controller } from '@hotwired/stimulus';

const DEFAULT_ITEMS_SELECTOR = ':scope > :is(div, fieldset)';
var ButtonType;
(function (ButtonType) {
ButtonType[ButtonType["Add"] = 0] = "Add";
ButtonType[ButtonType["Delete"] = 1] = "Delete";
})(ButtonType || (ButtonType = {}));
class default_1 extends Controller {
connect() {
this.connectCollection(this.element);
}
connectCollection(parent) {
parent.querySelectorAll('[data-prototype]').forEach((el) => {
const collectionEl = el;
const items = this.getItems(collectionEl);
collectionEl.dataset.currentIndex = items.length.toString();
this.addAddButton(collectionEl);
this.getItems(collectionEl).forEach((itemEl) => this.addDeleteButton(collectionEl, itemEl));
});
}
getItems(collectionElement) {
return collectionElement.querySelectorAll(collectionElement.dataset.itemsSelector || DEFAULT_ITEMS_SELECTOR);
}
createButton(collectionEl, buttonType) {
var _a;
const attributeName = `${ButtonType[buttonType].toLowerCase()}Button`;
const button = (_a = collectionEl.dataset[attributeName]) !== null && _a !== void 0 ? _a : this.element.dataset[attributeName];
console.log(button);
if ('' === button)
return null;
if (undefined === button || !('content' in document.createElement('template'))) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = ButtonType[buttonType];
return button;
}
const buttonTemplate = document.getElementById(button);
if (!buttonTemplate)
throw new Error(`template with ID "${buttonTemplate}" not found`);
const fragment = buttonTemplate.content.cloneNode(true);
if (1 !== fragment.children.length)
throw new Error('template with ID "${buttonTemplateID}" must have exactly one child');
return fragment.firstElementChild;
}
addItem(collectionEl) {
const currentIndex = collectionEl.dataset.currentIndex;
collectionEl.dataset.currentIndex++;
const collectionNamePattern = collectionEl.id.replace(/_/g, '(?:_|\\[|]\\[)');
const prototype = collectionEl.dataset.prototype
.replace('__name__label__', currentIndex)
.replace(new RegExp(`(${collectionNamePattern}(?:_|]\\[))__name__`, 'g'), `$1${currentIndex}`);
const fakeEl = document.createElement('div');
fakeEl.innerHTML = prototype;
const itemEl = fakeEl.firstElementChild;
this.connectCollection(itemEl);
this.addDeleteButton(collectionEl, itemEl);
const items = this.getItems(collectionEl);
items.length ? items[items.length - 1].insertAdjacentElement('afterend', itemEl) : collectionEl.prepend(itemEl);
}
addAddButton(collectionEl) {
const addButton = this.createButton(collectionEl, ButtonType.Add);
if (!addButton)
return;
addButton.onclick = (e) => {
e.preventDefault();
this.addItem(collectionEl);
};
collectionEl.appendChild(addButton);
}
addDeleteButton(collectionEl, itemEl) {
const deleteButton = this.createButton(collectionEl, ButtonType.Delete);
if (!deleteButton)
return;
deleteButton.onclick = (e) => {
e.preventDefault();
itemEl.remove();
};
itemEl.appendChild(deleteButton);
}
}
default_1.values = {
addButton: '',
deleteButton: '',
};

export { default_1 as default };
1 change: 1 addition & 0 deletions src/Collection/Resources/assets/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('../../../../jest.config.js');
24 changes: 24 additions & 0 deletions src/Collection/Resources/assets/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "@symfony/ux-collection",
"description": "Support for collection embedding with Symfony Form",
"license": "MIT",
"main": "dist/controller.js",
"module": "dist/controller.js",
"version": "1.0.0",
"symfony": {
"controllers": {
"collection": {
"main": "dist/controller.js",
"webpackMode": "eager",
"fetch": "eager",
"enabled": true
}
}
},
"peerDependencies": {
"@hotwired/stimulus": "^3.0.0"
},
"devDependencies": {
"@hotwired/stimulus": "^3.0.0"
}
}
115 changes: 115 additions & 0 deletions src/Collection/Resources/assets/src/controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { Controller } from '@hotwired/stimulus';

const DEFAULT_ITEMS_SELECTOR = ':scope > :is(div, fieldset)';

interface CollectionDataset extends DOMStringMap {
prototype: string;
currentIndex: string;
itemsSelector?: string;
addButton?: string;
deleteButton?: string;
}

enum ButtonType {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be a const enum instead, to inline the add and remove string in the emitted JS code instead of emitting an enum object.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed the code and using const enum isn't possible anymore (IIRC, const enum causes various issues anyway).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another alternative is to use a tagged union 'add' | 'remove', which will then produce more efficient JS code.

Add,
Delete,
}

export default class extends Controller {
static values = {
addButton: '',
deleteButton: '',
};

connect() {
this.connectCollection(this.element as HTMLElement);
}

connectCollection(parent: HTMLElement) {
parent.querySelectorAll('[data-prototype]').forEach((el) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still want to add here a comment that this the most questionable line in my point of view.

In #400 I implemented the JS the way I think it should be implemented that every collection itself has its collection_controller and not that one controller controls all collections.

So we even don't need to wrap the whole form with a data-controller.

Why I would avoid the data-prototype selector here is that for example I have inside my current forms other Form Types using [data-prototye] which are even inside collection types, this component currently would identify them also as "collection" which they are not they just use the data-prototype for different usecases.

Via a form extension like in #400 we can add a better way by adding data-controller to all collection which would solve this problem and even allows to create collection type with a custom stimulus controller like in #90 or #397 also allows extending the current implementaton via events.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll make this selector configurable and split this controller in two (the main one in case you want all collections to be automatically handled, and a nested one). WDYT?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It still is hard to find here a correct selector as the main problems is that the component is registered on the form tag and handles multiple instances of collections.

As I did use component libraries over the last years which work very simular to stimulus I would really not recommend creating components like this which handles multiple instances. Instead I would make sure to have a collection_controller which handles the a single collection, which should make also the code simpler.

What problem do you see that the JS component handles just a single collection and adding a data-controller via form extension to the collection row like in #400.

const collectionEl = el as HTMLElement;
const items = this.getItems(collectionEl);
collectionEl.dataset.currentIndex = items.length.toString();

this.addAddButton(collectionEl);

this.getItems(collectionEl).forEach((itemEl) => this.addDeleteButton(collectionEl, itemEl as HTMLElement));
});
}

getItems(collectionElement: HTMLElement) {
return collectionElement.querySelectorAll(collectionElement.dataset.itemsSelector || DEFAULT_ITEMS_SELECTOR);
}

createButton(collectionEl: HTMLElement, buttonType: ButtonType): HTMLElement | null {
const attributeName = `${ButtonType[buttonType].toLowerCase()}Button`;
const button = collectionEl.dataset[attributeName] ?? (this.element as HTMLElement).dataset[attributeName];
console.log(button);

// Button explicitly disabled through data attribute
if ('' === button) return null;

// No data attribute provided or <template> not supported: create raw HTML button
if (undefined === button || !('content' in document.createElement('template'))) {
const button = document.createElement('button') as HTMLButtonElement;
button.type = 'button';
button.textContent = ButtonType[buttonType];

return button;
}

// Use the template referenced by the data attribute
const buttonTemplate = document.getElementById(button) as HTMLTemplateElement | null;
if (!buttonTemplate) throw new Error(`template with ID "${buttonTemplate}" not found`);

const fragment = buttonTemplate.content.cloneNode(true) as DocumentFragment;
if (1 !== fragment.children.length)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this could use fragment.childElementCount

throw new Error('template with ID "${buttonTemplateID}" must have exactly one child');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this needs to use backticks, not single quotes. Otherwise the interpolation won't work.


return fragment.firstElementChild as HTMLElement;
}

addItem(collectionEl: HTMLElement) {
const currentIndex = (collectionEl.dataset as CollectionDataset).currentIndex;
(collectionEl.dataset.currentIndex as unknown as number)++;

const collectionNamePattern = collectionEl.id.replace(/_/g, '(?:_|\\[|]\\[)');

const prototype = (collectionEl.dataset.prototype as string) // We're sure that dataset.prototype exists, because of the CSS selector used in connect()
.replace('__name__label__', currentIndex)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The __name__ part is configuration in CollectionType (which is necessary to support nested collection types, as you should then use different prototype names for them))

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually my code supports nested collections even when not using another placeholder, but I'll add support for this option!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No it does not. The prototype of the outer collection actually contains the attribute storing the prototype of the inner collection (as it contains the whole inner collection). If you use the same placeholder for both, the replacement done here will replace both placeholders inside the inner prototype, breaking the inner collection.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Try doing a nested collection where you add multiple items in the inner collection of an item added in the outer collection, and then look at the submitted data in the Request (you need to inspect the request itself, not the data decoded in $_POST as duplicate keys would be lost during the conversion to a PHP array)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the replacement done here will replace both placeholders inside the inner prototype

I didn't handle the label yet, but for __name__, the regex only replaces the first occurrence, which should be enough to fix this issue. I'll double-check.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

replacing only the first occurrence is also broken. __name__ will appear multiple time in the prototype when the entry type is a compound type (as the name of each child form will have the placeholder in its name.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first occurence in every string: https://github.com/symfony/ux/pull/398/files#diff-0c84303846c77e8d3377b23960aed4a1dfc3db0bb6206c7435ef6fe890a7fa1bR49

Unless I'm missing something, this works with the current example I provided for test purpose.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dunglas AFAICT, this would replace both placecholders in name="root[outer_collection][__name__][inner_collection][__name][field_1]"
And trying to do clever tricks based on the quote delimiting attributes (to replace the first occurrence in each attribute) is a bad idea: in the prototype attribute of the nested collection, those would be HTML entities and not actual quotes.

Note that you need to add multiple items in each collection to notice the issue (if you only add one outer item with a single inner item, you won't notice whether the [0] come from the replacements done by the outer or inner item)

.replace(new RegExp(`(${collectionNamePattern}(?:_|]\\[))__name__`, 'g'), `$1${currentIndex}`);

const fakeEl = document.createElement('div');
fakeEl.innerHTML = prototype;
const itemEl = fakeEl.firstElementChild as HTMLElement;

this.connectCollection(itemEl);

this.addDeleteButton(collectionEl, itemEl);

const items = this.getItems(collectionEl);
items.length ? items[items.length - 1].insertAdjacentElement('afterend', itemEl) : collectionEl.prepend(itemEl);
}

addAddButton(collectionEl: HTMLElement) {
const addButton = this.createButton(collectionEl, ButtonType.Add);
if (!addButton) return;

addButton.onclick = (e) => {
e.preventDefault();
this.addItem(collectionEl);
};
collectionEl.appendChild(addButton);
}

addDeleteButton(collectionEl: HTMLElement, itemEl: HTMLElement) {
const deleteButton = this.createButton(collectionEl, ButtonType.Delete);
if (!deleteButton) return;

deleteButton.onclick = (e) => {
e.preventDefault();
itemEl.remove();
};
itemEl.appendChild(deleteButton);
}
}
Loading