Feat/disable services option for groups (#711)

* add servicesDisabled field for groups and for global configuration

* fixup! add servicesDisabled field for groups and for global configuration

* comment libcurl upgrade to fix ci temporarly

* upgrade caddy version

* review

* fix test

* feat: add paying membership option (#714)

* feat: add paying membership option

* fix: ci

* fix: phpstan + review

* fix: eslint ci
This commit is contained in:
Sarahshr 2024-07-10 15:24:09 +02:00 committed by Hugo Nicolas
parent a045a787a5
commit b9a87a420b
No known key found for this signature in database
GPG key ID: 09CB3D93EB8B0E61
52 changed files with 1023 additions and 221 deletions

View file

@ -51,6 +51,6 @@ jobs:
- name: Twig Linter
run: docker compose exec -T php ./vendor/bin/twigcs templates/ --exclude vendor
- name: Install eslint
run: docker run --rm -w "/usr/app" -v "${PWD}":/usr/app gmolaire/yarn yarn add eslint
run: docker run --rm -w "/usr/app" -v "${PWD}":/usr/app gmolaire/yarn yarn add eslint@8.57.0
- name: Run eslint on javascript files
run: docker run --rm -w "/usr/app" -v "${PWD}":/usr/app gmolaire/yarn yarn lint

View file

@ -5,7 +5,7 @@
# https://docs.docker.com/engine/reference/builder/#understand-how-arg-and-from-interact
ARG PHP_VERSION=8.1
ARG CADDY_VERSION=2
ARG CADDY_VERSION=2.8.4
# yarn build
FROM gmolaire/yarn AS yarn_build
@ -24,7 +24,7 @@ RUN yarn build
FROM php:${PHP_VERSION}-fpm-alpine AS app_php
# needed for security update until base image is updated
RUN apk upgrade libcurl curl openssl openssl-dev libressl libcrypto3 libssl3
#RUN apk upgrade libcurl curl openssl openssl-dev libressl libcrypto3 libssl3
# Allow to use development versions of Symfony
ARG STABILITY="stable"
@ -188,7 +188,7 @@ RUN rm -f .env.local.php
# Temporary fix for https://github.com/dunglas/mercure/issues/770
# https://github.com/dunglas/symfony-docker/pull/407/files
FROM caddy:2.7-builder-alpine AS app_caddy_builder
FROM caddy:2.8.4-builder-alpine AS app_caddy_builder
# RUN xcaddy build \
# --with github.com/dunglas/mercure \
@ -204,7 +204,7 @@ RUN xcaddy build \
FROM caddy:${CADDY_VERSION} AS app_caddy
# needed for security update until base image is updated
RUN apk upgrade libcurl curl openssl openssl-dev libressl libcrypto1.1 libssl1.1 libcrypto3 libssl3
#RUN apk upgrade libcurl curl openssl openssl-dev libressl libcrypto1.1 libssl1.1 libcrypto3 libssl3
WORKDIR /srv/app

8
assets/admin.js Normal file
View file

@ -0,0 +1,8 @@
import { startStimulusApp } from '@symfony/stimulus-bridge'
import AdminParentgroup from './controllers/admin_parentgroup_controller'
import './styles/admin.css'
const app = startStimulusApp()
app.register('admin-parentgroup', AdminParentgroup)

View file

@ -0,0 +1,77 @@
import { Controller} from '@hotwired/stimulus'
/* stimulusFetch: 'lazy' */
export default class extends Controller {
static targets = ['servicesEnabledField', 'parentField', 'idField']
parentFieldTargetConnected(element) {
const observer = new MutationObserver( ( ) => {
if (element.tomselect) {
observer.disconnect()
const toggle = document.getElementById('Group_servicesEnabled')
toggle.addEventListener('change', () => {
this.updateParentOptions(toggle.checked, this.parentFieldTarget)
})
}
})
observer.observe(element, {attributes: true})
}
async updateParentOptions(servicesEnabled, parentField) {
const url = `/api/groups?services_enabled=${servicesEnabled}`
const response = await fetch(url, { method: 'GET' })
if (!response.ok) {
return
}
const data = await response.json()
const groups = data['hydra:member']
// Remove options
parentField.tomselect.clearOptions()
// Populate with new options
groups.map(group => {
parentField.tomselect.addOption(new Option(group.name, group.id))
})
}
servicesEnabledFieldTargetConnected() {
this.servicesEnabledFieldTarget.addEventListener('change', () => {
if(!this.servicesEnabledFieldTarget.checked) {
const params = new URLSearchParams(this.servicesEnabledFieldTarget.getAttribute('data-toggle-url'))
this.disableServicesForChildGroups(params.get('entityId'))
}
})
}
async disableServicesForChildGroups(groupId) {
const url = `/api/groups/${groupId}/disable_child_services`
const response = await fetch(url, {
method: 'PATCH',
headers: {
'Content-Type': 'application/merge-patch+json',
},
})
if (!response.ok) {
return
}
const data = await response.json()
const groupChild = data.children
const groupChildId = groupChild.map(group => {
return group.split('/')[3]
})
const allToggles = document.querySelectorAll('[data-admin-parentgroup-target="servicesEnabledField"]')
Array.from(allToggles).map(toggle => {
const params = new URLSearchParams(toggle.getAttribute('data-toggle-url'))
if(groupChildId.includes(params.get('entityId'))) {
toggle.checked = false
}
})
}
}

View file

@ -0,0 +1,36 @@
import { Controller} from '@hotwired/stimulus'
export default class extends Controller {
static targets = ['servicesEnabledField', 'parentField']
connect() {
}
updateParentOptions() {
const userId = this.servicesEnabledFieldTarget.getAttribute('data-user-id')
const servicesEnabled = this.servicesEnabledFieldTarget.checked
const url = `/api/groups?user=${userId}&services_enabled=${servicesEnabled}`
this.addGroupsWithEnabledServices(url)
}
async addGroupsWithEnabledServices(url) {
const response = await fetch(url, { method: 'GET' })
if (!response.ok) {
return
}
const data = await response.json()
const groups = data['hydra:member']
// Remove options and set a default value
Array.from(this.parentFieldTarget.options).map((group) => {
this.parentFieldTarget.remove(group)
})
this.parentFieldTarget.add(new Option())
// Populate with new options
groups.map(group => {
this.parentFieldTarget.add(new Option(group.name, group.id))
})
}
}

View file

@ -1,5 +1,4 @@
import { startStimulusApp } from '@symfony/stimulus-bridge'
import { Application } from '@hotwired/stimulus'
import PasswordVisibility from 'stimulus-password-visibility'
import Carousel from 'stimulus-carousel'
@ -15,7 +14,5 @@ export const app = startStimulusApp(require.context(
// register any custom, 3rd party controllers here
// app.register('some_controller_name', SomeImportedController);
const application = Application.start()
application.register('carousel', Carousel)
application.register('password-visibility', PasswordVisibility)
app.register('carousel', Carousel)
app.register('password-visibility', PasswordVisibility)

4
assets/styles/admin.css Normal file
View file

@ -0,0 +1,4 @@
/* Remove the extra arrow added by Tom Select */
.ts-wrapper.single .ts-control:after {
display: none;
}

3
assets/styles/app.css Normal file
View file

@ -0,0 +1,3 @@
body {
background-color: skyblue;
}

View file

@ -78,6 +78,7 @@
"symfony/runtime": "6.2.*",
"symfony/security-bundle": "6.2.*",
"symfony/serializer": "6.2.*",
"symfony/stimulus-bundle": "^2.14",
"symfony/translation-contracts": "^3.2",
"symfony/twig-bridge": "6.2.*",
"symfony/twig-bundle": "6.2.*",

73
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "3e1f8a3631e991b528d9f68758299e0e",
"content-hash": "2b97a3771217b9b8a9f07e3f5ec9aec2",
"packages": [
{
"name": "alcohol/iso4217",
@ -13247,6 +13247,75 @@
],
"time": "2023-03-01T10:32:47+00:00"
},
{
"name": "symfony/stimulus-bundle",
"version": "v2.14.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/stimulus-bundle.git",
"reference": "f775f6e811215156bfe41e6be234272d0c27e02b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/stimulus-bundle/zipball/f775f6e811215156bfe41e6be234272d0c27e02b",
"reference": "f775f6e811215156bfe41e6be234272d0c27e02b",
"shasum": ""
},
"require": {
"php": ">=8.1",
"symfony/config": "^5.4|^6.0|^7.0",
"symfony/dependency-injection": "^5.4|^6.0|^7.0",
"symfony/deprecation-contracts": "^2.0|^3.0",
"symfony/finder": "^5.4|^6.0|^7.0",
"symfony/http-kernel": "^5.4|^6.0|^7.0",
"twig/twig": "^2.15.3|^3.4.3"
},
"require-dev": {
"symfony/asset-mapper": "^6.3|^7.0",
"symfony/framework-bundle": "^5.4|^6.0|^7.0",
"symfony/phpunit-bridge": "^5.4|^6.0|^7.0",
"symfony/twig-bundle": "^5.4|^6.0|^7.0",
"zenstruck/browser": "^1.4"
},
"type": "symfony-bundle",
"autoload": {
"psr-4": {
"Symfony\\UX\\StimulusBundle\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Integration with your Symfony app & Stimulus!",
"keywords": [
"symfony-ux"
],
"support": {
"source": "https://github.com/symfony/stimulus-bundle/tree/v2.14.2"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2024-02-07T20:26:48+00:00"
},
{
"name": "symfony/stopwatch",
"version": "v6.2.7",
@ -18590,5 +18659,5 @@
"ext-zip": "*"
},
"platform-dev": [],
"plugin-api-version": "2.3.0"
"plugin-api-version": "2.6.0"
}

View file

@ -33,4 +33,5 @@ return [
Payum\Bundle\PayumBundle\PayumBundle::class => ['all' => true],
FOS\CKEditorBundle\FOSCKEditorBundle::class => ['all' => true],
Misd\PhoneNumberBundle\MisdPhoneNumberBundle::class => ['all' => true],
Symfony\UX\StimulusBundle\StimulusBundle::class => ['all' => true],
];

View file

@ -4,8 +4,9 @@ App\Entity\Configuration:
features (extends configuration_template):
configuration:
services:
servicesEnabled: true
global:
globalServicesEnabled: true
globalPaidMembership: false
notificationsSender:
notificationsSenderEmail: info@example.com
notificationsSenderName: Contact

View file

@ -4,8 +4,9 @@ App\Entity\Configuration:
features (extends configuration_template):
configuration:
services:
servicesEnabled: true
global:
globalServicesEnabled: true
globalPaidMembership: false
notificationsSender:
notificationsSenderEmail: info@example.com
notificationsSenderName: Contact

View file

@ -4,11 +4,11 @@ App\Entity\GroupOffer:
# Templates
group_offer_group_1_template (template, extends group_offer_template):
group: '@group_1'
type: !php/enum App\Enum\Group\GroupOfferType::YEARLY
type: !php/enum App\Enum\OfferType::YEARLY
group_offer_group_7_template (template, extends group_offer_template):
group: '@group_7'
type: !php/enum App\Enum\Group\GroupOfferType::YEARLY
type: !php/enum App\Enum\OfferType::YEARLY
# Group 1
group_offer_group_1_10 (extends group_offer_group_1_template):

View file

@ -0,0 +1,11 @@
App\Entity\PlatformOffer:
platform_offer_1:
id: <uuid('9040b3fb-8a01-4bbf-a228-ca9f90db5034')>
name: Lorem ipsum
price: 2000
type: !php/enum App\Enum\OfferType::YEARLY
platform_offer_2:
name: Aliquet risus
price: 200
type: !php/enum App\Enum\OfferType::MONTHLY

View file

@ -19,6 +19,7 @@ use App\Form\Type\Security\GroupInvitationFormType;
use App\Helper\CsvExporter;
use App\Message\Command\Group\CreateGroupInvitationMessage;
use App\MessageBus\CommandBus;
use App\Repository\ConfigurationRepository;
use App\Security\Checker\AuthorizationChecker;
use Doctrine\ORM\QueryBuilder;
use EasyCorp\Bundle\EasyAdminBundle\Collection\FieldCollection;
@ -66,6 +67,7 @@ final class GroupCrudController extends AbstractCrudController implements GroupA
private readonly TranslatorInterface $translator,
private readonly FilterFactory $filterFactory,
private readonly SluggerInterface $slugger,
private readonly ConfigurationRepository $configurationRepository
) {
}
@ -75,6 +77,7 @@ final class GroupCrudController extends AbstractCrudController implements GroupA
->setEntityLabelInPlural('groups')
->setSearchFields(['name', 'description'])
->setDefaultSort(['id' => 'ASC'])
->overrideTemplate('crud/field/boolean', 'admin/field/services_enabled.html.twig')
;
}
@ -230,8 +233,24 @@ final class GroupCrudController extends AbstractCrudController implements GroupA
->setFormTypeOption('class', GroupMembership::class)
->setChoices(GroupMembership::getAsArray());
if ($this->configurationRepository->getInstanceConfigurationOrCreate()->getServicesEnabled()) {
$servicesEnabledField = BooleanField::new('servicesEnabled')
->renderAsSwitch()
->setFormTypeOption('attr', [
'data-controller' => 'admin-parentgroup',
'data-admin-parentgroup-target' => 'servicesEnabledField',
])
->addWebpackEncoreEntries('admin');
}
$parentField = AssociationField::new('parent')
->setRequired(false);
->setRequired(false)
->addWebpackEncoreEntries('admin')
->setFormTypeOption('attr', [
'data-controller' => 'admin-parentgroup',
'data-admin-parentgroup-target' => 'parentField',
])
;
$childrenField = AssociationField::new('children');
$usersField = AssociationField::new('userGroups')
->setTemplatePath('admin/group/user_groups_field.html.twig');
@ -247,14 +266,20 @@ final class GroupCrudController extends AbstractCrudController implements GroupA
$panels = $this->getPanels();
if ($pageName === Crud::PAGE_INDEX) {
return [$nameField, $typeField, $parentField, $membershipField, $usersField, $createdAt, $updatedAt];
$fields = [$nameField, $typeField, $parentField, $membershipField, $usersField, $createdAt, $updatedAt];
if ($this->configurationRepository->getInstanceConfigurationOrCreate()->getServicesEnabled()) {
array_splice($fields, 3, 0, [$servicesEnabledField]);
}
return $fields;
}
if ($pageName === Crud::PAGE_NEW || $pageName === Crud::PAGE_EDIT) {
$typeField->setChoices(GroupType::cases());
$membershipField->setChoices(GroupMembership::cases());
return [
$fields = [
$nameField,
$typeField,
$membershipField,
@ -264,11 +289,17 @@ final class GroupCrudController extends AbstractCrudController implements GroupA
$invitationByAdminField,
$membershipField,
];
if ($this->configurationRepository->getInstanceConfigurationOrCreate()->getServicesEnabled()) {
array_splice($fields, 3, 0, [$servicesEnabledField]);
}
return $fields;
}
// show
return [
$fields = [
$panels['information'],
$nameField,
$parentField,
@ -283,6 +314,12 @@ final class GroupCrudController extends AbstractCrudController implements GroupA
$updatedAt,
$createdAt,
];
if ($this->configurationRepository->getInstanceConfigurationOrCreate()->getServicesEnabled()) {
array_splice($fields, 2, 0, [$servicesEnabledField]);
}
return $fields;
}
/**
@ -309,7 +346,7 @@ final class GroupCrudController extends AbstractCrudController implements GroupA
}
/**
* For now we export exactly what we see in the list to avoid seurity problems.
* For now we export exactly what we see in the list to avoid security problems.
*/
public function export(AdminContext $context): Response
{

View file

@ -10,12 +10,12 @@ use App\EasyAdmin\Field\FieldTrait;
use App\EasyAdmin\Filter\EnumFilter;
use App\EasyAdmin\Filter\UserGroup\MyGroupFilter;
use App\EasyAdmin\Filter\UuidFilter;
use App\EasyAdmin\Form\Type\GroupOfferTypeType;
use App\EasyAdmin\Form\Type\OfferTypeType;
use App\Entity\GroupOffer;
use App\Entity\User;
use App\Enum\Group\GroupMembership;
use App\Enum\Group\GroupOfferType;
use App\Enum\Group\UserMembership;
use App\Enum\OfferType;
use App\Security\Checker\AuthorizationChecker;
use Doctrine\ORM\QueryBuilder;
use EasyCorp\Bundle\EasyAdminBundle\Collection\FieldCollection;
@ -65,7 +65,7 @@ final class GroupOfferCrudController extends AbstractCrudController implements G
return $filters
->add(UuidFilter::new('id'))
->add(MyGroupFilter::new('group'))
->add(EnumFilter::new('membership', GroupOfferTypeType::class))
->add(EnumFilter::new('membership', OfferTypeType::class))
->add('name')
->add('active')
;
@ -129,8 +129,8 @@ final class GroupOfferCrudController extends AbstractCrudController implements G
$nameField = TextField::new('name');
$typeField = ChoiceField::new('type')
->setFormType(EnumType::class)
->setFormTypeOption('class', GroupOfferType::class)
->setChoices(GroupOfferType::getAsArray());
->setFormTypeOption('class', OfferType::class)
->setChoices(OfferType::getAsArray());
$priceField = MoneyField::new('price')
->setCurrencyPropertyPath('currency')
@ -149,7 +149,7 @@ final class GroupOfferCrudController extends AbstractCrudController implements G
}
if ($pageName === Crud::PAGE_NEW || $pageName === Crud::PAGE_EDIT) {
$typeField->setChoices(GroupOfferType::cases());
$typeField->setChoices(OfferType::cases());
return [
$groupField,

View file

@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin;
use App\Controller\FlashTrait;
use App\Controller\i18nTrait;
use App\EasyAdmin\Field\FieldTrait;
use App\EasyAdmin\Filter\EnumFilter;
use App\EasyAdmin\Filter\UuidFilter;
use App\EasyAdmin\Form\Type\OfferTypeType;
use App\Entity\PlatformOffer;
use App\Enum\OfferType;
use EasyCorp\Bundle\EasyAdminBundle\Config\Action;
use EasyCorp\Bundle\EasyAdminBundle\Config\Actions;
use EasyCorp\Bundle\EasyAdminBundle\Config\Crud;
use EasyCorp\Bundle\EasyAdminBundle\Config\Filters;
use EasyCorp\Bundle\EasyAdminBundle\Controller\AbstractCrudController;
use EasyCorp\Bundle\EasyAdminBundle\Field\BooleanField;
use EasyCorp\Bundle\EasyAdminBundle\Field\ChoiceField;
use EasyCorp\Bundle\EasyAdminBundle\Field\CurrencyField;
use EasyCorp\Bundle\EasyAdminBundle\Field\DateTimeField;
use EasyCorp\Bundle\EasyAdminBundle\Field\IdField;
use EasyCorp\Bundle\EasyAdminBundle\Field\MoneyField;
use EasyCorp\Bundle\EasyAdminBundle\Field\TextField;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
final class PlatformOfferCrudController extends AbstractCrudController implements AdminSecuredCrudControllerInterface
{
use FlashTrait;
use FieldTrait;
use i18nTrait;
public function configureCrud(Crud $crud): Crud
{
return $crud
->setEntityLabelInPlural('platform_offers')
->setSearchFields(['name'])
;
}
public function configureFilters(Filters $filters): Filters
{
return $filters
->add(UuidFilter::new('id'))
->add(EnumFilter::new('type', OfferTypeType::class))
->add('name')
->add('active')
;
}
public function configureActions(Actions $actions): Actions
{
return $actions
->add(Crud::PAGE_INDEX, Action::DETAIL)
->add(Crud::PAGE_EDIT, Action::DETAIL)
->add(Crud::PAGE_EDIT, Action::INDEX)
;
}
public static function getEntityFqcn(): string
{
return PlatformOffer::class;
}
public function configureFields(string $pageName): iterable
{
$idFIeld = IdField::new('id')
->setLabel('id')
->hideOnForm();
$nameField = TextField::new('name');
$typeField = ChoiceField::new('type')
->setFormType(EnumType::class)
->setFormTypeOption('class', OfferType::class)
->setChoices(OfferType::getAsArray());
$priceField = MoneyField::new('price')
->setCurrencyPropertyPath('currency')
->setStoredAsCents();
$currencyField = CurrencyField::new('currency');
$activeField = BooleanField::new('active')
->setTemplatePath('easy_admin/field/boolean.html.twig')
;
$createdAtField = DateTimeField::new('createdAt');
$updatedAtField = DateTimeField::new('updatedAt');
$panels = $this->getPanels();
if ($pageName === Crud::PAGE_INDEX) {
return [$nameField, $typeField, $priceField, $activeField, $createdAtField, $updatedAtField];
}
if ($pageName === Crud::PAGE_NEW || $pageName === Crud::PAGE_EDIT) {
$typeField->setChoices(OfferType::cases());
return [
$nameField,
$typeField,
$priceField,
$currencyField,
$activeField,
];
}
// show
return [
$panels['information'],
$nameField,
$typeField,
$priceField,
$currencyField,
$panels['tech_information'],
$idFIeld,
$updatedAtField,
$createdAtField,
];
}
}

View file

@ -0,0 +1,148 @@
<?php
declare(strict_types=1);
namespace App\Doctrine\Behavior;
use App\Enum\OfferType;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\IdGenerator\UuidGenerator;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\MappedSuperclass]
abstract class AbstractOfferEntity implements \Stringable
{
use TimestampableEntity;
final public const DEFAULT_CURRENCY = 'EUR';
/**
* Generates a V6 uuid.
*/
#[ORM\Id]
#[ORM\Column(type: 'uuid', unique: true)]
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
#[ORM\CustomIdGenerator(class: UuidGenerator::class)]
protected Uuid $id;
/**
* Short name of the offer.
*/
#[ORM\Column(type: Types::STRING, length: 255, nullable: false)]
#[Assert\NotBlank]
#[Assert\Length(max: 255)]
protected string $name;
/**
* Type of offer.
*/
#[ORM\Column(name: 'type', type: 'string', nullable: false, enumType: OfferType::class)]
#[Assert\NotBlank]
protected OfferType $type;
/**
* Price, we stored the amount multiplied by 100 so we can use an integer for
* this property.
*/
#[ORM\Column(type: Types::INTEGER, nullable: false)]
protected int $price;
/**
* Associated currency for the price property.
*
* @see https://en.wikipedia.org/wiki/ISO_4217
*/
#[ORM\Column(type: Types::STRING, nullable: false)]
protected string $currency = self::DEFAULT_CURRENCY;
/**
* If the offer is visible on the front site. Can be used to deactivate offers
* for some time.
*/
#[ORM\Column(type: 'boolean', nullable: false)]
protected bool $active = true;
public function __toString()
{
return $this->name.' ('.$this->type->value.')';
}
public function getId(): Uuid
{
return $this->id;
}
public function setId(Uuid $uuid): self
{
$this->id = $uuid;
return $this;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
public function getType(): OfferType
{
return $this->type;
}
public function setType(OfferType $type): self
{
$this->type = $type;
return $this;
}
public function setPrice(int $price): self
{
$this->price = $price;
return $this;
}
public function getPrice(): int
{
return $this->price;
}
public function getActualPrice(): int
{
return $this->price / 100;
}
public function getCurrency(): string
{
return $this->currency;
}
public function setCurrency(string $currency): self
{
$this->currency = $currency;
return $this;
}
public function isActive(): bool
{
return $this->active;
}
public function setActive(bool $active): self
{
$this->active = $active;
return $this;
}
}

View file

@ -5,20 +5,20 @@ declare(strict_types=1);
namespace App\EasyAdmin\Form\Type;
use App\Controller\Admin\DashboardController;
use App\Enum\Group\GroupOfferType;
use App\Enum\OfferType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Form type for the GroupOfferType enumeration.
* Form type for the OfferType enumeration.
*/
class GroupOfferTypeType extends AbstractType
class OfferTypeType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'choices' => GroupOfferType::getAsArray(),
'choices' => OfferType::getAsArray(),
'translation_domain' => DashboardController::DOMAIN,
]);
}

View file

@ -78,19 +78,26 @@ class Configuration
/**
* @return bool[]
*/
public function getServices(): array
public function getGlobals(): array
{
/** @var array<string, bool> $services */
$services = $this->configuration['services'] ?? [];
/** @var array<string, bool> $globals */
$globals = $this->configuration['global'] ?? [];
return $services;
return $globals;
}
public function getServicesEnabled(): bool
{
$services = $this->getServices();
$globals = $this->getGlobals();
return $services['servicesEnabled'];
return $globals['globalServicesEnabled'];
}
public function getPaidMembership(): bool
{
$globals = $this->getGlobals();
return $globals['globalPaidMembership'];
}
/**

View file

@ -7,10 +7,15 @@ namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use App\Doctrine\Behavior\TimestampableEntity;
use App\Enum\Group\GroupMembership;
use App\Enum\Group\GroupType;
use App\Repository\GroupRepository;
use App\State\GroupsProvider;
use App\State\Processor\GroupChildServicesEnabledProcessor;
use App\Validator as AppAssert;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@ -26,6 +31,16 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Index(columns: ['type'])]
#[ApiFilter(OrderFilter::class, properties: ['name'])]
#[AppAssert\Constraints\Group\GroupParentNotSelf]
#[ApiResource(
operations: [
new GetCollection(provider: GroupsProvider::class),
new Patch(
uriTemplate: '/groups/{id}/disable_child_services',
input: false,
processor: GroupChildServicesEnabledProcessor::class
),
]
)]
class Group implements \Stringable
{
use TimestampableEntity;
@ -123,6 +138,9 @@ class Group implements \Stringable
#[ORM\ManyToMany(targetEntity: Product::class, mappedBy: 'groups')]
private Collection $products;
#[ORM\Column(type: 'boolean')]
private bool $servicesEnabled = false;
public function __construct()
{
$this->children = new ArrayCollection();
@ -340,6 +358,16 @@ class Group implements \Stringable
return $this;
}
public function getServicesEnabled(): bool
{
return $this->servicesEnabled;
}
public function setServicesEnabled(bool $servicesEnabled): void
{
$this->servicesEnabled = $servicesEnabled;
}
// End of basic 'etters ----------------------------------------------------
/**

View file

@ -4,33 +4,16 @@ declare(strict_types=1);
namespace App\Entity;
use App\Doctrine\Behavior\TimestampableEntity;
use App\Enum\Group\GroupOfferType;
use App\Doctrine\Behavior\AbstractOfferEntity;
use App\Repository\GroupOfferRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\IdGenerator\UuidGenerator;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: GroupOfferRepository::class)]
#[ORM\Table(name: 'group_offer')]
#[ORM\Index(columns: ['type'])]
class GroupOffer implements \Stringable
class GroupOffer extends AbstractOfferEntity
{
use TimestampableEntity;
final public const DEFAULT_CURRENCY = 'EUR';
/**
* Generates a V6 uuid.
*/
#[ORM\Id]
#[ORM\Column(type: 'uuid', unique: true)]
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
#[ORM\CustomIdGenerator(class: UuidGenerator::class)]
private Uuid $id;
/**
* Related group.
*/
@ -39,134 +22,15 @@ class GroupOffer implements \Stringable
#[ORM\OrderBy(['createdAt' => 'ASC'])]
private Group $group;
/**
* Short name of the offer.
*/
#[ORM\Column(type: Types::STRING, length: 255, nullable: false)]
#[Assert\NotBlank]
#[Assert\Length(max: 255)]
private string $name;
/**
* Type of offer.
*/
#[ORM\Column(name: 'type', type: 'string', nullable: false, enumType: GroupOfferType::class)]
#[Assert\NotBlank]
protected GroupOfferType $type;
/**
* Price, we stored the amount multiplied by 100 so we can use an integer for
* this property.
*/
#[ORM\Column(type: Types::INTEGER, nullable: false)]
protected int $price;
/**
* Associated currency for the price property.
*
* @see https://en.wikipedia.org/wiki/ISO_4217
*/
#[ORM\Column(type: Types::STRING, nullable: false)]
protected string $currency = self::DEFAULT_CURRENCY;
/**
* If the offer is visible on the front site. Can be use to deactivate offers
* for some time.
*/
#[ORM\Column(type: 'boolean', nullable: false)]
protected bool $active = true;
public function __toString()
{
return $this->name.' ('.$this->type->value.')';
}
public function getId(): Uuid
{
return $this->id;
}
public function setId(Uuid $uuid): self
{
$this->id = $uuid;
return $this;
}
public function getGroup(): Group
{
return $this->group;
}
public function setGroup(Group $group): GroupOffer
public function setGroup(Group $group): self
{
$this->group = $group;
return $this;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): GroupOffer
{
$this->name = $name;
return $this;
}
public function getType(): GroupOfferType
{
return $this->type;
}
public function setType(GroupOfferType $type): GroupOffer
{
$this->type = $type;
return $this;
}
public function setPrice(int $price): self
{
$this->price = $price;
return $this;
}
public function getPrice(): int
{
return $this->price;
}
public function getActualPrice(): int
{
return $this->price / 100;
}
public function getCurrency(): string
{
return $this->currency;
}
public function setCurrency(string $currency): self
{
$this->currency = $currency;
return $this;
}
public function isActive(): bool
{
return $this->active;
}
public function setActive(bool $active): GroupOffer
{
$this->active = $active;
return $this;
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Doctrine\Behavior\AbstractOfferEntity;
use App\Repository\PlatformOfferRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: PlatformOfferRepository::class)]
#[ORM\Table(name: 'platform_offer')]
#[ORM\Index(columns: ['type'])]
class PlatformOffer extends AbstractOfferEntity
{
}

View file

@ -39,7 +39,7 @@ use Symfony\Component\Validator\Constraints as Assert;
operations: [
new Patch(
uriTemplate: '/product/{id}/switchStatus',
openapiContext: ['summary' => 'Swicth the status of the product'],
openapiContext: ['summary' => 'Switch the status of the product'],
normalizationContext: ['groups' => [ProductSwitchProcessor::class]],
security: "is_granted('".ProductVoter::EDIT."', object)",
input: false,

View file

@ -770,15 +770,23 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, ImageIn
*
* @return Collection<int,Group>
*/
public function getMyGroupsAsAdmin(): Collection
public function getMyGroupsAsAdmin(bool $enabledServices = false): Collection
{
$adminUserGroups = $this->userGroups->filter(
static fn (UserGroup $userGroup) => $userGroup->getMembership()->isAdmin() || $userGroup->isMainAdminAccount()
);
return new ArrayCollection(
$groups = new ArrayCollection(
array_map(static fn (UserGroup $userGroup) => $userGroup->getGroup(), $adminUserGroups->toArray())
);
if ($enabledServices) {
return $groups->filter(
static fn (Group $group) => $group->getServicesEnabled()
);
}
return $groups;
}
/**

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use App\Doctrine\Behavior\TimestampableEntity;
use App\Enum\Group\UserMembership;
use App\Repository\UserGroupRepository;
@ -16,6 +17,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: UserGroupRepository::class)]
#[ORM\UniqueConstraint(columns: ['user', 'group'])]
#[ApiResource]
class UserGroup
{
use TimestampableEntity;

View file

@ -2,22 +2,20 @@
declare(strict_types=1);
namespace App\Enum\Group;
namespace App\Enum;
use App\Enum\AsArrayTrait;
enum GroupOfferType: string
enum OfferType: string
{
use AsArrayTrait;
// The user only to pay once to access the group. In his case the end date is
// The user only to pay once to access the group/platform. In his case the end date is
// not set and the membership is valid until it is deleted or a end date is
// set. The end date can always be set manually in case of a problem.
case ONESHOT = 'oneshot';
// Monthly subscription. The membership is valid 1 month and the user has to
// renew it once the end date is over. This can be useful when a user when to
// try a group on the short period before taking a longer subscription.
// try a group/platform on the short period before taking a longer subscription.
case MONTHLY = 'monthly';
// Subscription valid for one year. An email will be send a few days before

View file

@ -25,13 +25,20 @@ final class ParametersFormType extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('servicesEnabled', CheckboxType::class, [
->add('globalServicesEnabled', CheckboxType::class, [
'label' => 'parameter.services',
'label_attr' => [
'class' => 'checkbox-inline checkbox-switch',
],
])
->add('globalPaidMembership', CheckboxType::class, [
'label' => 'parameter.paid_membership',
'label_attr' => [
'class' => 'checkbox-inline checkbox-switch',
],
])
->add('notificationsSenderEmail', EmailType::class, [
'label' => 'parameter.mail',
'label_attr' => ['class' => 'col-sm-2 col-form-label'],

View file

@ -4,23 +4,30 @@ declare(strict_types=1);
namespace App\Form\Type\Group;
use App\Entity\Configuration;
use App\Entity\Group;
use App\Entity\User;
use App\Enum\Group\GroupMembership;
use App\Enum\Group\GroupType;
use App\Repository\ConfigurationRepository;
use App\Repository\GroupRepository;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\EnumType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Webmozart\Assert\Assert;
class CreateGroupFormType extends AbstractType
{
public function __construct(
private readonly Security $security,
private readonly ConfigurationRepository $configurationRepository,
private readonly GroupRepository $groupRepository
) {
}
@ -28,6 +35,8 @@ class CreateGroupFormType extends AbstractType
{
/** @var User $user */
$user = $this->security->getUser();
$configuration = $this->configurationRepository->getInstanceConfiguration();
$myGroupsWithDisabledServices = $this->groupRepository->getGroupsByEnabledServices(false, $user);
$builder
->add('name', TextType::class, [
@ -43,27 +52,44 @@ class CreateGroupFormType extends AbstractType
'label_attr' => ['class' => 'fs-6 text-black'],
'expanded' => true,
'choice_label' => 'transKey',
])
->add('membership', EnumType::class, [
'class' => GroupMembership::class,
'label' => 'templates.pages.group.create.form.membership',
'label_attr' => ['class' => 'fs-6 text-black'],
'expanded' => true,
])
->add('submit', SubmitType::class, [
'label' => 'templates.pages.group.create.form.submit',
'attr' => ['class' => 'btn btn-primary btn-sm d-grid col-12 my-5'],
]);
Assert::isInstanceOf($configuration, Configuration::class);
if ($configuration->getServicesEnabled()) {
$builder
->add('servicesEnabled', CheckboxType::class, [
'label' => 'templates.pages.group.create.form.servicesEnabled',
'label_attr' => ['class' => 'fs-6 text-black mb-3 switch-custom'],
'required' => false,
'attr' => [
'data-action' => 'click->parentgroup#updateParentOptions',
'data-parentgroup-target' => 'servicesEnabledField',
'data-user-id' => $user->getId(),
],
]);
}
$builder
->add('membership', EnumType::class, [
'class' => GroupMembership::class,
'label' => 'templates.pages.group.create.form.membership',
'label_attr' => ['class' => 'fs-6 text-black'],
'expanded' => true,
])
->add('submit', SubmitType::class, [
'label' => 'templates.pages.group.create.form.submit',
'attr' => ['class' => 'btn btn-primary btn-sm d-grid col-12 my-5'],
]);
$myGroups = $user->getMyGroupsAsAdmin();
if (!$myGroups->isEmpty()) {
if ([] === $myGroupsWithDisabledServices) {
$builder
->add('parent', EntityType::class, [
'class' => Group::class,
'choices' => $myGroups,
'choices' => $myGroupsWithDisabledServices,
'label' => 'templates.pages.group.create.form.subgroup',
'label_attr' => ['class' => 'fs-6 text-black'],
'required' => false,
'attr' => [
'data-parentgroup-target' => 'parentField',
],
]);
}
}

View file

@ -17,9 +17,12 @@ final class ParametersFormCommand extends AbstractFormCommand
final public const ONLY_ADMIN = 'only_admin';
final public const ALL = 'all';
// services section —————————————————————————————————————————————
// global section —————————————————————————————————————————————
#[Assert\Type('bool')]
public bool $servicesEnabled = true;
public bool $globalServicesEnabled = true;
#[Assert\Type('bool')]
public bool $globalPaidMembership = false;
// notificationsSender section —————————————————————————————————————————————
#[Assert\Email()]
@ -59,7 +62,7 @@ final class ParametersFormCommand extends AbstractFormCommand
protected function getSections(): array
{
return [
'services',
'global',
'notificationsSender',
'contact',
'groups',

View file

@ -6,13 +6,15 @@ namespace App\MessageHandler\Command\Admin;
use App\Message\Command\Admin\ParametersFormCommand;
use App\Repository\ConfigurationRepository;
use App\Repository\GroupRepository;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final class ParametersFormCommandHandler
{
public function __construct(
private readonly ConfigurationRepository $configurationRepository
private readonly ConfigurationRepository $configurationRepository,
private readonly GroupRepository $groupRepository,
) {
}
@ -24,5 +26,10 @@ final class ParametersFormCommandHandler
$configuration = $this->configurationRepository->getInstanceConfigurationOrCreate();
$configuration->setConfiguration($message->toJsonArray());
$this->configurationRepository->save($configuration, true);
if (!$configuration->getServicesEnabled()) {
$groups = $this->groupRepository->findAll();
$this->groupRepository->disableServicesForAllGroups($groups);
}
}
}

View file

@ -61,13 +61,13 @@ final class ConfigurationRepository extends ServiceEntityRepository
public function getServicesParameter(): bool
{
/** @var array{configuration: array{ services: array{ servicesEnabled: bool }}} $config */
/** @var array{configuration: array{ global: array{ globalServicesEnabled: bool }}} $config */
$config = $this
->createQueryBuilder('c')
->select('c.configuration')
->setMaxResults(1)
->getQuery()->getOneOrNullResult();
return $config['configuration']['services']['servicesEnabled'];
return $config['configuration']['global']['globalServicesEnabled'];
}
}

View file

@ -8,6 +8,7 @@ use App\Entity\Group;
use App\Entity\User;
use App\Entity\UserGroup;
use App\Enum\Group\GroupType;
use App\Enum\Group\UserMembership;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\Query;
use Doctrine\ORM\QueryBuilder;
@ -87,4 +88,51 @@ final class GroupRepository extends ServiceEntityRepository
->andWhere('ug.user = :user')
->setParameter('user', $user);
}
/**
* @return Group[]
*/
public function getGroupsByEnabledServices(bool $servicesEnabled, ?User $user = null): array
{
$qb = $this->createQueryBuilder('g')
->andWhere('g.servicesEnabled = :servicesEnabled')
->setParameter('servicesEnabled', $servicesEnabled);
if ($user instanceof User) {
$qb
->leftJoin('g.userGroups', 'gu')
->andWhere('gu.user = :user')
->andWhere('gu.mainAdminAccount = :mainAdminAccount OR gu.membership = :membership')
->setParameter('user', $user)
->setParameter('mainAdminAccount', true)
->setParameter('membership', UserMembership::ADMIN);
}
/** @var Group[] */
return $qb
->getQuery()
->getResult();
}
/**
* @param Group[] $groups
*/
public function disableServicesForAllGroups(array $groups): void
{
foreach ($groups as $group) {
$group->setServicesEnabled(false);
$this->getEntityManager()->persist($group);
}
$this->getEntityManager()->flush();
}
public function disableServicesForChildGroup(Group $group): void
{
/** @var Group $child */
foreach ($group->getChildren() as $child) {
$child->setServicesEnabled(false);
$this->getEntityManager()->persist($child);
}
$this->getEntityManager()->flush();
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\PlatformOffer;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<PlatformOffer>
*
* @method PlatformOffer|null find($id, $lockMode = null, $lockVersion = null)
* @method PlatformOffer|null findOneBy(array $criteria, array $orderBy = null)
* @method PlatformOffer[] findAll()
* @method PlatformOffer[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class PlatformOfferRepository extends ServiceEntityRepository
{
private const ENTITY_CLASS = PlatformOffer::class;
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, self::ENTITY_CLASS);
}
/**
* Return an object or throws an exception if not found.
*/
public function get(mixed $id, int|null $lockMode = null, int|null $lockVersion = null): PlatformOffer
{
return $this->find($id, $lockMode, $lockVersion) ?? throw new \LogicException('Platform offer not found.');
}
}

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Group;
use App\Repository\GroupRepository;
use App\Repository\UserRepository;
/**
* @implements ProviderInterface<Group>
*/
class GroupsProvider implements ProviderInterface
{
public function __construct(
readonly private GroupRepository $groupRepository,
readonly private UserRepository $userRepository
) {
}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null // @phpstan-ignore-line
{
if (isset($context['filters']['user'])) {
$user = $this->userRepository->find($context['filters']['user']);
return $this->groupRepository->getGroupsByEnabledServices($context['filters']['services_enabled'] === 'true', $user);
}
return $this->groupRepository->getGroupsByEnabledServices($context['filters']['services_enabled'] === 'true');
}
}

View file

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Group;
use App\Repository\GroupRepository;
use Webmozart\Assert\Assert;
class GroupChildServicesEnabledProcessor implements ProcessorInterface
{
public function __construct(
private readonly GroupRepository $groupRepository
) {
}
/**
* @param array<mixed> $uriVariables
* @param array<mixed> $context
*/
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Group
{
Assert::isInstanceOf($data, Group::class);
$this->groupRepository->disableServicesForChildGroup($data);
return $data;
}
}

View file

@ -185,6 +185,15 @@
"config/packages/http_discovery.yaml"
]
},
"phpstan/phpstan": {
"version": "1.11",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "1.0",
"ref": "5e490cc197fb6bb1ae22e5abbc531ddc633b6767"
}
},
"phpunit/phpunit": {
"version": "9.5",
"recipe": {
@ -444,6 +453,15 @@
"config/packages/security.yaml"
]
},
"symfony/stimulus-bundle": {
"version": "2.18",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.8",
"ref": "9e33a8a3794b603fb4be6c04ee5ecab901ce549e"
}
},
"symfony/translation": {
"version": "6.2",
"recipe": {

View file

@ -0,0 +1,17 @@
{# @var ea \EasyCorp\Bundle\EasyAdminBundle\Context\AdminContext #}
{# @var field \EasyCorp\Bundle\EasyAdminBundle\Dto\FieldDto #}
{# @var entity \EasyCorp\Bundle\EasyAdminBundle\Dto\EntityDto #}
{% trans_default_domain 'EasyAdminBundle' %}
{% if ea.crud.currentAction == 'detail' or not field.customOptions.get('renderAsSwitch') %}
<span class="badge {{ field.value == true ? 'badge-boolean-true' : 'badge-boolean-false' }}">
{{ (field.value == true ? 'label.true' : 'label.false')|trans }}
</span>
{% else %}
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input" id="{{ field.uniqueId }}" {{ field.value == true ? 'checked' }}
data-toggle-url="{{ field.customOptions.get('toggleUrl') }}"
{{ field.formTypeOption('disabled') == true ? 'disabled' }} autocomplete="off" data-controller="admin-parentgroup" data-admin-parentgroup-target="servicesEnabledField">
<label class="form-check-label" for="{{ field.uniqueId }}"></label>
</div>
{% endif %}

View file

@ -14,12 +14,28 @@
{% block main %}
{{ form_start(form) }}
<div class="row mb-lg-5">
<h2 class="h3 fw-bold mt-3">{{ 'parameters.services.h2'|trans }}</h2>
<h2 class="h3 fw-bold mt-3">{{ 'parameters.global.h2'|trans }}</h2>
<hr/>
{{ form_widget(form.servicesEnabled) }}
<h2 class="h5 fw-bold my-3">{{ 'parameters.services.h3'|trans }}</h2>
<h2 class="h3 fw-bold mt-3">{{ 'parameters.senders.h2'|trans }}</h2>
{{ form_widget(form.globalServicesEnabled) }}
<h2 class="h5 fw-bold my-3">{{ 'parameters.paid_membership.h3'|trans }}</h2>
{{ form_widget(form.globalPaidMembership) }}
<div class="col-2 my-3 mx-4">
<a
type="button"
class="btn btn-primary "
href="{{ ea_url().setController('App\\Controller\\Admin\\PlatformOfferCrudController').setAction('index') }}"
>
{{ 'parameter.set_price'|trans }}
</a>
</div>
<h2 class="h3 fw-bold mt-3 mb-1">{{ 'parameters.senders.h2'|trans }}</h2>
<hr/>
{{ form_row(form.notificationsSenderEmail) }}

View file

@ -17,9 +17,20 @@
{{ form_row(form.name) }}
{{ form_row(form.type) }}
{{ form_row(form.membership) }}
{% if form.parent is defined and form.parent is not null %}
{{ form_row(form.parent) }}
{% endif %}
<div {{ stimulus_controller('parentgroup') }}>
{% if form.servicesEnabled is defined %}
<div
{{ stimulus_target('parentgroup', 'servicesField') }}
data-product-route-value="{{ path('app_group_list') }}"
>
{{ form_widget(form.servicesEnabled) }}
</div>
{% endif %}
{% if form.parent is defined and form.parent is not null %}
{{ form_row(form.parent) }}
{% endif %}
</div>
{{ form_widget(form.submit) }}
{{ form_end(form) }}
</div>

View file

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Controller\Admin;
use App\Controller\Admin\PlatformOfferCrudController;
use App\Enum\OfferType;
use App\Test\KernelTrait;
use App\Tests\TestReference;
use Hautelook\AliceBundle\PhpUnit\RefreshDatabaseTrait;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
final class PlatformOfferCrudControllerTest extends WebTestCase
{
use KernelTrait;
use RefreshDatabaseTrait;
/**
* @see PlatformOfferCrudController
*/
public function testController(): void
{
$client = self::createClient();
$this->loginAsAdmin($client);
// list+custom filters
$filters = '&filters[type]='.(OfferType::MONTHLY->isMonthly() ? '1' : '0');
$client->request('GET', sprintf(TestReference::ADMIN_URL, 'index', PlatformOfferCrudController::class.'&'.$filters));
self::assertResponseIsSuccessful();
// edit
$client->request('GET', sprintf(TestReference::ADMIN_URL.'&entityId=%s', 'edit', PlatformOfferCrudController::class, TestReference::PLATFORM_OFFER_1));
self::assertResponseIsSuccessful();
// detail
$client->request('GET', sprintf(TestReference::ADMIN_URL.'&entityId=%s', 'detail', PlatformOfferCrudController::class, TestReference::PLATFORM_OFFER_1));
self::assertResponseIsSuccessful();
// new
$crawler = $client->request('GET', sprintf(TestReference::ADMIN_URL, 'new', PlatformOfferCrudController::class));
self::assertResponseIsSuccessful();
$form = $crawler->selectButton(TestReference::ACTION_SAVE_AND_RETURN)->form();
$client->submit($form, [
$form->getName().'[name]' => 'New special offer',
$form->getName().'[type]' => 'yearly',
$form->getName().'[price]' => 490,
$form->getName().'[currency]' => 'EUR',
$form->getName().'[active]' => false,
]);
self::assertResponseRedirects();
$client->followRedirect();
self::assertResponseIsSuccessful();
}
}

View file

@ -33,7 +33,7 @@ final class CreateGroupActionTest extends WebTestCase
$client->submit($form, [
$form->getName().'[name]' => 'Groupe 1',
$form->getName().'[type]' => 'public',
$form->getName().'[parent]' => TestReference::GROUP_1,
$form->getName().'[servicesEnabled]' => false,
]);
self::assertResponseRedirects();
self::assertTrue(u($client->getResponse()->headers->get('Location'))->startsWith('http://localhost/admin'));

View file

@ -127,4 +127,7 @@ final class TestReference
// payments
final public const PAYMENT_USER_16_1 = '1edcefc9-45b3-6a3e-b4a6-db137f56da56';
// platform offer
final public const PLATFORM_OFFER_1 = '9040b3fb-8a01-4bbf-a228-ca9f90db5034';
}

View file

@ -39,7 +39,7 @@ final class DoneActionTest extends TestCase
->setId($this->getUuid())
->setSlug('group');
return (new GroupOffer())
return (new GroupOffer()) // @phpstan-ignore-line
->setId($this->getUuid())
->setGroup($group);
}

View file

@ -4,14 +4,14 @@ declare(strict_types=1);
namespace App\Tests\Unit\Enum\Group;
use App\Enum\Group\GroupOfferType;
use App\Enum\OfferType;
use PHPUnit\Framework\TestCase;
final class GroupOfferTypeTest extends TestCase
{
public function testGroupOfferType(): void
{
self::assertTrue(GroupOfferType::MONTHLY->isMonthly());
self::assertTrue(GroupOfferType::YEARLY->isYearly());
self::assertTrue(OfferType::MONTHLY->isMonthly());
self::assertTrue(OfferType::YEARLY->isYearly());
}
}

View file

@ -260,11 +260,21 @@
<target>Paramètres de l'instance</target>
</trans-unit>
<trans-unit id="e1qNBMp" resname="parameters.services.h2">
<source>parameters.services.h2</source>
<trans-unit id="e1qNBMp" resname="parameters.global.h2">
<source>parameters.global.h2</source>
<target>Paramètres généraux de la plateforme</target>
</trans-unit>
<trans-unit id="z9SxLWb" resname="parameters.services.h3">
<source>parameters.services.h3</source>
<target>Services</target>
</trans-unit>
<trans-unit id="z2cGYub" resname="parameters.paid_membership.h3">
<source>parameters.paid_membership.h3</source>
<target>Adhésion payante</target>
</trans-unit>
<trans-unit id="eNqNAUs" resname="parameters.senders.h2">
<source>parameters.senders.h2</source>
<target>Expéditeur·rice des notifications</target>

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
<file source-language="en" target-language="fr" datatype="plaintext" original="file.ext">
<header>
<tool tool-id="symfony" tool-name="Symfony" />
</header>
<body>
<trans-unit id="M1x1Kq4" resname="platform_offers">
<source>platform_offers</source>
<target>Tarifs d'adhésion pour la plateforme</target>
</trans-unit>
<trans-unit id="W2z6Ls8" resname="PlatformOffer">
<source>PlatformOffer</source>
<target>Tarifs d'adhésion</target>
</trans-unit>
</body>
</file>
</xliff>

View file

@ -25,6 +25,11 @@
<target>Envoi d'invitations par les admins possible</target>
</trans-unit>
<trans-unit id="KBpmXZn" resname="Services Enabled">
<source>Services Enabled</source>
<target>Activer la disponibilité des services pour le groupe</target>
</trans-unit>
<trans-unit id="MQA7gVL" resname="Invitation By Moderator">
<source>Invitation By Moderator</source>
<target>Envoi d'invitations par les modérateur·rice·s possible</target>

View file

@ -6,12 +6,22 @@
</header>
<body>
<!-- notification sender section -->
<!-- global section -->
<trans-unit id="qlPbwB3" resname="parameter.services">
<source>parameter.services</source>
<target>Services activés</target>
</trans-unit>
<trans-unit id="swXqmJ4" resname="parameter.paid_membership">
<source>parameter.paid_membership</source>
<target>Adhésion à la plateforme payante</target>
</trans-unit>
<trans-unit id="qsLAkx6" resname="parameter.set_price">
<source>parameter.set_price</source>
<target>Configurer le tarif</target>
</trans-unit>
<!-- notification sender section -->
<trans-unit id="qqTbwBV" resname="parameter.mail">
<source>parameter.mail</source>

View file

@ -26,6 +26,11 @@
<target>Type</target>
</trans-unit>
<trans-unit id="BOAMpGn" resname="templates.pages.group.create.form.servicesEnabled">
<source>templates.pages.group.create.form.servicesEnabled</source>
<target>Activer la disponibilité des services pour le groupe</target>
</trans-unit>
<trans-unit id="qBmcBDh" resname="templates.pages.group.create.form.membership">
<source>templates.pages.group.create.form.membership</source>
<target>Tarif</target>

View file

@ -21,6 +21,7 @@ Encore
* and one CSS file (e.g. app.css) if your JavaScript imports CSS.
*/
.addEntry('app', './assets/app.js')
.addEntry('admin', './assets/admin.js')
// enables the Symfony UX Stimulus bridge (used in assets/bootstrap.js)
.enableStimulusBridge('./assets/controllers.json')