Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
Add Collection component
  • Loading branch information
dunglas committed Jul 18, 2022
commit e901026aa1d2e2cd2b27a5796962bc8fc4b5dcca
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

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)
71 changes: 71 additions & 0 deletions src/Collection/Resources/assets/dist/controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { Controller } from '@hotwired/stimulus';

const DEFAULT_ITEMS_SELECTOR = ':scope > :is(div, fieldset)';
var ButtonType;
(function (ButtonType) {
ButtonType["Add"] = "add";
ButtonType["Delete"] = "delete";
})(ButtonType || (ButtonType = {}));
class controller 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) {
const buttonTemplateID = collectionEl.dataset[`${buttonType}ButtonTemplateId`];
if (buttonTemplateID && 'content' in document.createElement('template')) {
const buttonTemplate = document.getElementById(buttonTemplateID);
if (!buttonTemplate)
throw new Error(`element with ID "${buttonTemplateID}" not found`);
return buttonTemplate.content.cloneNode(true);
}
const button = document.createElement('button');
button.type = 'button';
button.textContent = buttonType === ButtonType.Add ? 'Add' : 'Delete';
return button;
}
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);
addButton.onclick = (e) => {
e.preventDefault();
this.addItem(collectionEl);
};
collectionEl.appendChild(addButton);
}
addDeleteButton(collectionEl, itemEl) {
const deleteButton = this.createButton(collectionEl, ButtonType.Delete);
deleteButton.onclick = (e) => {
e.preventDefault();
itemEl.remove();
};
itemEl.appendChild(deleteButton);
}
}

export { controller 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"
}
}
98 changes: 98 additions & 0 deletions src/Collection/Resources/assets/src/controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { Controller } from '@hotwired/stimulus';

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

interface CollectionDataset extends DOMStringMap {
prototype: string;
currentIndex: string;
itemsSelector?: string;
addButtonTemplateId?: string;
disableAddButton?: string;
deleteButtonTemplateId?: string;
disableDeleteButton?: string;
}

enum ButtonType {
Add = 'add',
Delete = 'delete',
}

export default class extends Controller {
connect() {
this.connectCollection(this.element as HTMLElement);
}

connectCollection(parent: HTMLElement) {
parent.querySelectorAll('[data-prototype]').forEach((el) => {
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 {
const buttonTemplateID = collectionEl.dataset[`${buttonType}ButtonTemplateId`];
if (buttonTemplateID && 'content' in document.createElement('template')) {
// Get from template
const buttonTemplate = document.getElementById(buttonTemplateID) as HTMLTemplateElement | null;
if (!buttonTemplate) throw new Error(`element with ID "${buttonTemplateID}" not found`);

return buttonTemplate.content.cloneNode(true) as HTMLElement;
}

// If no template is provided, create a raw HTML button
const button = document.createElement('button') as HTMLButtonElement;
button.type = 'button';
button.textContent = buttonType === ButtonType.Add ? 'Add' : 'Delete';

return button;
}

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)
.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);
addButton.onclick = (e) => {
e.preventDefault();
this.addItem(collectionEl);
};
collectionEl.appendChild(addButton);
}

addDeleteButton(collectionEl: HTMLElement, itemEl: HTMLElement) {
const deleteButton = this.createButton(collectionEl, ButtonType.Delete);
deleteButton.onclick = (e) => {
e.preventDefault();
itemEl.remove();
};
itemEl.appendChild(deleteButton);
}
}
40 changes: 40 additions & 0 deletions src/Collection/Resources/assets/test/controller.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* 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.
*/

'use strict';

import { Application } from '@hotwired/stimulus';
import { getByTestId } from '@testing-library/dom';
import { clearDOM, mountDOM } from '@symfony/stimulus-testing';
import CollectionController from '../src/controller';

const startStimulus = () => {
const application = Application.start();
application.register('symfony--ux-collection--collection', CollectionController);
};

/* eslint-disable no-undef */
describe('CollectionController', () => {
let container: any;

beforeEach(() => {
container = mountDOM('<div data-testid="collection" data-controller="symfony--ux-collection--collection"></div>');
});

afterEach(() => {
clearDOM();
});

it('connects', async () => {
startStimulus();

// smoke test
expect(getByTestId(container, 'collection')).toHaveAttribute('data-controller', 'symfony--ux-collection--collection');
});
});
40 changes: 40 additions & 0 deletions src/Collection/Tests/app/Entity/Game.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

namespace App\Entity;

/**
* @author Kévin Dunglas <[email protected]>
*/
class Game
{
public \DateTimeImmutable $date;
/**
* @var Team[]
*/
private array $teams = [];

public function addTeam(Team $team): void
{
$this->teams[] = $team;
$this->teams = array_values($this->teams);

dump($this->teams);
}

public function removeTeam(Team $team): void
{
if (false === $i = array_search($team, $this->teams, true)) {
return;
}

unset($this->teams[$i]);
$this->teams = array_values($this->teams);
}

public function getTeams(): array
{
return $this->teams;
}
}
14 changes: 14 additions & 0 deletions src/Collection/Tests/app/Entity/Player.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

namespace App\Entity;

/**
* @author Kévin Dunglas <[email protected]>
*/
class Player
{
public string $firstName;
public string $lastName;
}
Loading