diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..105bfdf0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,84 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## About this bundle + +`FrameworkCoreBundle` is a Symfony 8 bundle used as a shared foundation across SumoCoders projects. It is not a +standalone application. It is installed into projects via `sumocoders/application-skeleton`. + +Requirements: + +- PHP ^8.5 +- Symfony ^8.0, +- Doctrine ^3.3. + +## Commands + +```bash +# Install dependencies +composer install + +# Run tests +./vendor/bin/phpunit + +# Run a single test file +./vendor/bin/phpunit tests/path/to/FooTest.php +``` + +No phpstan or phpcs config exists in this repo. Those are configured per-project in the consuming application. + +## Architecture + +### Request lifecycle + +Two event listeners fire on every request: + +1. `BreadcrumbListener` (`kernel.controller_arguments`, priority -1): reads `#[Breadcrumb]` attributes from the matched + controller and populates `BreadcrumbTrail`. +2. `TitleListener` (`kernel.controller_arguments`, priority -1): reads `#[Title]` attributes and writes to `PageTitle`. + Falls back to breadcrumbs if no `#[Title]` is present. + +`PageTitle` and `BreadcrumbTrail` are request-scoped services aliased for direct injection. + +### Attribute-driven configuration + +Controller behaviour is controlled via PHP 8 attributes, not YAML or annotations: + +| Attribute | Target | Purpose | +|--------------------|-----------------|----------------------------------------------------------------------------------------------------| +| `#[Breadcrumb]` | method / class | Adds one crumb; repeatable for chains; supports `parent:` for automatic trail building | +| `#[Title]` | method | Explicit page title; supports `{param}` / `{object.property}` interpolation and `parent:` chaining | +| `#[AuditTrail]` | entity class | Enables Doctrine audit logging for that entity; `withData: false` skips field-level diff | +| `#[SensitiveData]` | entity property | Masks property value in audit log output | + +### Subsystems + +- **Pagination** (`src/Pagination/Paginator.php`): wraps a Doctrine `QueryBuilder`; exposes current page, total + results, prev/next. See `docs/pagination.md`. +- **Menu** (`src/Menu/MenuBuilder.php`): KnpMenu builder dispatching `ConfigureMenuEvent`; consuming apps listen to + that event to add items. See `docs/menu.md`. +- **Audit trail** (`src/DoctrineListener/DoctrineAuditListener.php`): Doctrine `onFlush` + `postPersist` listener that + logs creates/updates/deletes via `AuditLogger`. Entities opt in with `#[AuditTrail]`. See `docs/audit-trail.md`. +- **Forms** (`src/Form/`): custom types (`ImageType`, `FileType`, `BelgiumPostCodeType`) and extensions wiring date + pickers, toggle-password, and collection UI. See `docs/forms.md`. +- **DBAL types** (`src/DBALType/`): `EncryptedDBALType`, `AbstractImageType`, `AbstractFileType` for custom column + handling. See `docs/encrypted.md`, `docs/uploading-files.md`, `docs/uploading-images.md`. +- **Twig** (`src/Twig/`): `FrameworkExtension`, `PaginatorExtension`, `ContentExtension`; `PageTitle` is available as a + string in templates. +- **Doctrine extension** (`src/Extensions/Doctrine/MatchAgainst.php`): custom DQL function for MySQL + `MATCH ... AGAINST` full-text search. + +### Service registration + +All services are registered in `config/services.php` (PHP-format DI config, no YAML). The bundle extension +(`src/DependencyInjection/SumoCodersFrameworkCoreExtension.php`) loads that file. `Configuration.php` is intentionally +empty. No runtime bundle config is needed. + +## Documentation + +All docs live in `docs/`. Start at `docs/index.md` for an overview of all subsystems, the request lifecycle, and key +injectable services. + +When adding or changing a subsystem, update the corresponding doc file in `docs/`. Each doc follows the format: purpose, +prerequisites, usage, options table, examples, troubleshooting. diff --git a/README.md b/README.md index daaf4501..b0274d6a 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,31 @@ # SumoCoders FrameworkCoreBundle -This bundle is created and maintained by [SumoCoders](https://github.com/sumocoders). It contains a set of basic tools that enable us to build an application in a shorter timespan. The bundle is intended to be used together with our [npm package](https://github.com/sumocoders/FrameworkStylePackage). + +Shared foundation bundle for SumoCoders Symfony projects. Provides page titles, breadcrumbs, navigation menus, +file/image uploads, encrypted fields, audit logging, pagination, and a frontend design system. Created and maintained +by [SumoCoders](https://github.com/sumocoders). ## Installation -To properly use this bundle, create a new project with our application skeleton: -``` -$ composer create-project sumocoders/application-skeleton my_project + +Create a new project with the application skeleton. It installs this bundle and all required config: + +```bash +composer create-project sumocoders/application-skeleton my_project ``` -The skeleton will load this bundle, install our npm package and add some extra config for CI, deployment, etc.. + +Requires: + +- PHP ^8.5 +- Symfony ^8.0 +- Doctrine ^3.3. ## Documentation -All documentation is located in the `docs/` subdirectory. + +Start at [`docs/index.md`](docs/index.md). ## Issues? + Feel free to add an Issue on Github, or even better create a PR. ## License + This software is published under the [MIT License](LICENSE.md) diff --git a/docs/frontend/ajax-client.md b/docs/ajax-client.md similarity index 72% rename from docs/frontend/ajax-client.md rename to docs/ajax-client.md index 55127661..29b57c9a 100644 --- a/docs/frontend/ajax-client.md +++ b/docs/ajax-client.md @@ -1,6 +1,17 @@ # AJAX client -The AJAX client is a simple wrapper around [`Axios`](https://axios-http.com/). +A pre-configured [Axios](https://axios-http.com/) wrapper with CSRF support, toast notifications, and busy-button +spinners. Provided by the bundle's JavaScript assets. + +## Prerequisites + +JavaScript assets must be installed: see [installation.md](installation.md). + +Import the client in your Stimulus controller: + +```javascript +import ajaxClient from '../js/ajax_client.js' +``` ## Default Axios Config @@ -116,7 +127,7 @@ A simple way to "protect" the AJAX calls is by using a CSRF token. This is done ```javascript ajaxClient.csrf_token = this.csrfTokenValue ajaxClient.post(this.urlValue, data) - ... +... ``` With this the csrf token is added to the payload of the request, with the key `csrf_token`. @@ -136,5 +147,28 @@ The content of a clicked button can be replaced by a spinner during the request. ```javascript ajaxClient.busy_targets = [buttonNode] ajaxClient.post(this.urlValue, data) - ... +... ``` + +## File upload (multipart) + +For file uploads, use `FormData` and set `Content-Type` to `multipart/form-data`: + +```javascript +const formData = new FormData() +formData.append('file', fileInput.files[0]) + +ajaxClient.post(this.urlValue, formData, { + headers: { 'Content-Type': 'multipart/form-data' } +}).then(response => { + // handle response +}) +``` + +## Troubleshooting + +- **CSRF token invalid**: ensure the token id passed to `isCsrfTokenValid()` on the server matches the id used to + generate the token in Twig +- **Toast not showing**: verify the response JSON contains a `message` key; without it, no toast is triggered +- **Request times out immediately**: the default timeout is 2500ms; override it per request: + `ajaxClient.get(url, { timeout: 10000 })` diff --git a/docs/frontend/asset-mapper.md b/docs/asset-mapper.md similarity index 82% rename from docs/frontend/asset-mapper.md rename to docs/asset-mapper.md index 5fd20ee7..c2bdb1df 100644 --- a/docs/frontend/asset-mapper.md +++ b/docs/asset-mapper.md @@ -1,6 +1,7 @@ # Asset mapper -We use Symfony asset mapper for our css and js packages. See Symfony documentation for more information: https://symfony.com/doc/current/frontend/asset_mapper.html +We use Symfony asset mapper for our css and js packages. See Symfony documentation for more +information: https://symfony.com/doc/current/frontend/asset_mapper.html ## Add package @@ -44,8 +45,8 @@ symfony console importmap:install ## Our CSS -Our main CSS is currently located in the framework-core-bundle as this still needs to be compile with scss for Bootstrap. -This CSS is included in the `assets/styles/style.scss` file. +Our main CSS is located in the framework-core-bundle as this still needs to be compile with scss for Bootstrap. This CSS +is included in the `assets/styles/style.scss` file. To compile the CSS with SASS, you can run the following command: diff --git a/docs/development/audit-trail.md b/docs/audit-trail.md similarity index 51% rename from docs/development/audit-trail.md rename to docs/audit-trail.md index 34a692e0..19aa4459 100644 --- a/docs/development/audit-trail.md +++ b/docs/audit-trail.md @@ -1,14 +1,16 @@ # Audit trail -## Introduction +Logs entity creates, updates, and deletes to the `audit_trail` Monolog channel. Disabled by default. Entities opt in +with `#[AuditTrail]`. -The audit trail is a feature that allows you to track changes to your data. -It is useful for tracking changes to sensitive data, such as user accounts, or for tracking changes to data that is -important for compliance, such as financial records. +## Prerequisites + +No extra configuration required. The `DoctrineAuditListener` is registered automatically. To capture logs, configure a +`audit_trail` channel in `config/packages/monolog.yaml`. ## Usage -The audit trail is NOT enabled by default. You will need to add the `#[AuditTrial]` attribute to the entity you want to track. +Add `#[AuditTrail]` to any entity class. The attribute is NOT added by default. ```php #[ORM\Entity] @@ -31,7 +33,8 @@ class Book [2024-09-06T08:30:40.145881+00:00] audit_trail.INFO: Source: https://test.wip/trail; Entity: App\Entity\Book; Identifier: 1; Action: C; User: test@sumocoders.be; Roles: ROLE_ADMIN, ROLE_USER; IP: 127.0.0.1; Fields: []; Data: {"title":"The Lord of the Rings","author":"J. R. R. Tolkien","price":40.50} [] [] ``` -By default the following data is tracked: +By default, the following data is tracked: + * The date and time of the action * The source of the action (e.g. the url of the request, the command that was run, etc.) * The entity that was changed @@ -43,7 +46,28 @@ By default the following data is tracked: * The fields that were changed * The data that was changed -You can specify which fields you want to track by adding the specifying the `field` property in the `#[AuditTrail]` attribute. +## `#[AuditTrail]` options + +| Option | Type | Default | Description | +|------------|---------|---------|---------------------------------------------------------------------------| +| `fields` | `array` | `[]` | Limit tracking to these field names. Empty array = all fields | +| `withData` | `bool` | `true` | Include old/new values in the log. Set to `false` to log field names only | + +## Log format + +``` +[datetime] audit_trail.INFO: Source: ; Entity: ; Identifier: ; Action: ; User: ; Roles: ; IP: ; Fields: []; Data: +``` + +**Action codes:** + +| Code | Meaning | +|------|----------------------------------------------| +| `C` | Create (entity persisted for the first time) | +| `U` | Update (entity modified) | +| `D` | Delete (entity removed) | + +## Filter to specific fields ```php #[ORM\Entity] @@ -68,6 +92,7 @@ class Book You can hide secure data from the audit trail by adding the `#[SensitiveData]` attribute to the property. This will transform the data to `****` in the audit trail. + ```php #[AuditTrail] #[ORM\Entity] @@ -90,7 +115,8 @@ class User [2024-09-06T09:48:53.540500+00:00] audit_trail.INFO: Source: https://test.wip/profile; Entity: App\Entity\User; Identifier: 2; Action: U; User: test@sumocoders.be; Roles: ROLE_ADMIN, ROLE_USER; IP: 127.0.0.1; Fields: ["password"]; Data: {"password":{"from":"*****","to":"*****"}} [] [] ``` -There is also an option to only track the fields that are changes without the data, with the option `withData` set to `false`. +There is also an option to only track the fields that are changes without the data, with the option `withData` set to +`false`. ```php #[AuditTrail(withData: false)] @@ -113,26 +139,42 @@ class User [2024-09-06T09:48:53.540500+00:00] audit_trail.INFO: Source: https://test.wip/profile; Entity: App\Entity\User; Identifier: 2; Action: U; User: test@sumocoders.be; Roles: ROLE_ADMIN, ROLE_USER; IP: 127.0.0.1; Fields: ["password"]; Data: []} [] [] ``` -### Manually tracking changes +## Manually tracking actions -You can manually track changes by using the `AuditLogger` service. +Inject `AuditLogger` to log non-Doctrine actions (e.g. exports, logins, API calls): ```php -class TestController extends AbstractController +log( - data: ['test' => 'test'], - ); - - return $this->render( - 'test/index.html.twig', - [] - ); + public function __invoke(AuditLogger $auditLogger): Response + { + $auditLogger->log(data: ['format' => 'csv', 'rows' => 1500]); + + return $this->file('export.csv'); } } ``` + +## Performance note + +The listener fires on every Doctrine `onFlush` event. For entities that change very frequently, use `fields:` to narrow +which changes are logged, or set `withData: false` to skip the field diff computation. + +## Troubleshooting + +- **No log entries appearing**: verify the `audit_trail` Monolog channel is configured and the log file/handler is + writable +- **All fields tracked instead of specific ones**: `fields:` must list the exact property names as defined on the + entity, not the column names +- **Sensitive data visible**: add `#[SensitiveData]` to the property; the value will be masked as `*****` in both old + and new values diff --git a/docs/development/breadcrumb.md b/docs/breadcrumb.md similarity index 60% rename from docs/development/breadcrumb.md rename to docs/breadcrumb.md index a7c778ee..ebf30634 100644 --- a/docs/development/breadcrumb.md +++ b/docs/breadcrumb.md @@ -1,8 +1,47 @@ -# Using the breadcrumb +# Breadcrumbs + +Populates a `BreadcrumbTrail` service from `#[Breadcrumb]` attributes on controller classes and methods. The trail is +available for rendering in Twig on every request. + +## Prerequisites + +No additional configuration required. `BreadcrumbListener` fires automatically on `kernel.controller_arguments`. + +## `#[Breadcrumb]` options + +| Parameter | Type | Default | Description | +|--------------|---------------|----------|--------------------------------------------------------------------------------------------------| +| `title` | `string` | required | Crumb label. Supports `{object.property}` interpolation. Passed through the translator | +| `route` | `array\|null` | `null` | Makes the crumb a link. Keys: `name` (required), `parameters` (optional array) | +| `parent` | `array\|null` | `null` | Prepends the full trail of another route. Keys: `name` (required), `parameters` (optional array) | +| `parameters` | `array` | `[]` | Translation parameters. Values are `object.property` paths resolved from controller arguments | + +The attribute targets both **methods** and **classes**, and is **repeatable**. Multiple `#[Breadcrumb]` on the same +element are added in declaration order. + +## Rendering in Twig + +```twig +{% for crumb in breadcrumbTrail %} + {% if loop.last %} + + {% else %} + + {% endif %} +{% endfor %} +``` + +`breadcrumbTrail` is available automatically in all templates via the bundle's Twig extension. ## Basics -Add a `#[Breadcrumb]` attribute to a controller method to register a crumb. The attribute is repeatable — each one +Add a `#[Breadcrumb]` attribute to a controller method to register a crumb. The attribute is repeatable. Each one appends a crumb to the trail in declaration order. ```php @@ -48,7 +87,7 @@ class BooksController ## Linked breadcrumbs (`route:`) Pass `route:` to make the crumb a link. Required route parameters are automatically resolved from the current -controller's named arguments and request attributes — you do not need to specify them manually. +controller's named arguments and request attributes. You do not need to specify them manually. ```php #[Route('/books/genres', name: 'genres_overview')] @@ -111,7 +150,7 @@ public function __invoke(Author $author, Book $book): Response } ``` -> Scalar parameters (e.g. `string $name`) cannot be used with the `{name}` syntax — only objects with a property path +> Scalar parameters (e.g. `string $name`) cannot be used with the `{name}` syntax. Only objects with a property path > are supported. Using a scalar silently omits the breadcrumb. ## Translations @@ -169,3 +208,14 @@ public function __invoke(Author $author, Book $book): Response ```yaml breadcrumb.authors: 'Authors' ``` + +## Troubleshooting + +- **Breadcrumb not appearing**: verify the controller method has `#[Breadcrumb]` (not the class alone, unless it is an + invokable controller) +- **`{object.property}` shows literally**: scalars (e.g. `string $name`) cannot be interpolated; only object arguments + with accessible properties work +- **Parent chain stops early**: every route in the chain must have its own `#[Breadcrumb]` attribute; missing one + breaks the recursive resolution +- **Translation key not found**: breadcrumb titles are translated using the default domain; add the key to + `translations/messages..yaml` diff --git a/docs/development/button-locations.md b/docs/button-locations.md similarity index 68% rename from docs/development/button-locations.md rename to docs/button-locations.md index fcaffd78..b0531546 100644 --- a/docs/development/button-locations.md +++ b/docs/button-locations.md @@ -1,5 +1,8 @@ # Button locations +The bundle layout has two toolbar regions: one below the header (for secondary actions) and a fixed bottom bar (for +primary actions). Use the correct region to maintain consistent UX across the app. + ## Toolbar below header Buttons in the toolbar below the header are used for secondary actions that users infrequently need to access. @@ -12,10 +15,12 @@ Put them in {% block header_navigation %} ``` ## Fixed toolbar on the bottom + Buttons in the fixed toolbar on the bottom are used for primary actions that users frequently need to access. They are always visible, even when the user scrolls down the page. There are three positions: + - **Left** `{% block header_actions_left %}` -- typically used for "dangerous" actions like delete - **Center** `{% block header_actions_center %}` -- other actions that are frequently used but not primary - **Right** `{% block header_actions_right %}` -- typically used for primary actions like save or add @@ -47,4 +52,13 @@ When using a form, you can place the submit button in the fixed toolbar by addin {% endblock %} ``` -The important part is `form="{{ form.vars.id }}"` which links the button to the form. +The important part is `form="{{ form.vars.id }}"` which links the button to the form even though it sits outside the +`
` element. + +## Accessibility + +- Use `type="submit"` for submit buttons and `type="button"` for actions that do not submit a form. Missing `type` + defaults to submit, which can trigger unintended form submissions +- Destructive actions (delete) belong in `header_actions_left` so users do not accidentally activate them when reaching + for the save button +- Include visible text or an `aria-label` on icon-only buttons diff --git a/docs/crud.md b/docs/crud.md new file mode 100644 index 00000000..83c5a954 --- /dev/null +++ b/docs/crud.md @@ -0,0 +1,934 @@ +# CRUD + +A standard CRUD in this bundle uses four invokable controllers, a shared DataTransferObject, a single form type, and +Symfony Messenger for mutations. + +## Prerequisites + +- Pagination in the repository (see [pagination.md](pagination.md)) +- Button placement conventions (see [button-locations.md](button-locations.md)) +- Ask whether audit logging is needed before adding `#[AuditTrail]` to the entity (see [audit-trail.md](audit-trail.md)) +- Ask whether a menu item should be added after the CRUD is in place (see [menu.md](menu.md)) + +## File structure + +``` +src/ + Controller/ + Item/ + Admin/ + IndexController.php + CreateController.php + UpdateController.php + DeleteController.php + DataTransferObject/ + Item/ + ItemDataTransferObject.php + Exception/ + Item/ + ItemNotFoundException.php + Message/ + Item/ + CreateItemMessage.php + UpdateItemMessage.php + DeleteItemMessage.php + MessageHandler/ + Item/ + CreateItemMessageHandler.php + UpdateItemMessageHandler.php + DeleteItemMessageHandler.php + Form/ + Item/ + ItemType.php + Entity/ + Item.php + Repository/ + ItemRepository.php +templates/ + item/ + index.html.twig + create.html.twig + update.html.twig +translations/ + messages.en.yaml +tests/ + MessageHandler/ + Item/ + CreateItemMessageHandlerTest.php + UpdateItemMessageHandlerTest.php + DeleteItemMessageHandlerTest.php + Controller/ + Item/ + Admin/ + IndexControllerTest.php + CreateControllerTest.php + UpdateControllerTest.php + DeleteControllerTest.php +``` + +## Entity + +The constructor is the only way to create an entity. The `update()` method takes explicit arguments, not a +DataTransferObject. + +```php +name = $name; + } + + public function getId(): ?int { return $this->id; } + public function getName(): string { return $this->name; } +} +``` + +## DataTransferObject + +`ItemDataTransferObject` holds the form fields for both create and update. Use the constructor to set initial values. + +```php +getData()` returns the message +directly and can be dispatched without conversion. + +```php +id = $item->getId(); + parent::__construct($item->getName()); + } +} +``` + +`DeleteItemMessage` accepts the entity but stores only the id. + +```php +id = $item->getId(); + } +} +``` + +## Exception + +```php + */ +final class ItemType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->add('name', TextType::class); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults(['data_class' => ItemDataTransferObject::class]); + } +} +``` + +## Repository + +`save()` and `remove()` accept an optional `$flush` parameter, defaulting to `true`. + +```php +createQueryBuilder('i')->orderBy('i.name', 'ASC') + ); + } + + public function save(Item $item, bool $flush = true): void + { + $this->getEntityManager()->persist($item); + if ($flush) { + $this->getEntityManager()->flush(); + } + } + + public function remove(Item $item, bool $flush = true): void + { + $this->getEntityManager()->remove($item); + if ($flush) { + $this->getEntityManager()->flush(); + } + } +} +``` + +## Message handlers + +```php +repository->save(new Item($message->name)); + } +} +``` + +```php +repository->find($message->id); + if (!$item instanceof Item) { + throw new ItemNotFoundException(); + } + + $item->update($message->name); + $this->repository->save($item); + } +} +``` + +```php +repository->find($message->id); + if (!$item instanceof Item) { + throw new ItemNotFoundException(); + } + + $this->repository->remove($item); + } +} +``` + +## Controllers + +Inject dependencies via the constructor. Use `$messageBus` as the variable name for `MessageBusInterface`. Place +`#[Route]` and `#[Breadcrumb]` attributes on `__invoke`, not on the class. + +### IndexController + +```php +repository->getPaginated() + ->paginate($request->query->getInt('page', 1)); + + return $this->render('item/index.html.twig', ['items' => $items]); + } +} +``` + +### CreateController + +```php + 'item_index'])] + #[Breadcrumb('item.breadcrumb.create')] + public function __invoke(Request $request): Response + { + $form = $this->createForm(ItemType::class, new CreateItemMessage()); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + // @mago-expect analysis:mixed-argument + $this->messageBus->dispatch($form->getData()); + + $this->addFlash('success', $this->translator->trans('item.flash.created')); + + return $this->redirectToRoute('item_index'); + } + + return $this->render('item/create.html.twig', ['form' => $form]); + } +} +``` + +### UpdateController + +```php + 'item_index'])] + #[Breadcrumb('{item.name}')] + public function __invoke(Request $request, Item $item): Response + { + $form = $this->createForm(ItemType::class, new UpdateItemMessage($item)); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + // @mago-expect analysis:mixed-argument + $this->messageBus->dispatch($form->getData()); + + $this->addFlash('success', $this->translator->trans('item.flash.updated')); + + return $this->redirectToRoute('item_index'); + } + + $deleteForm = $this->createFormBuilder() + ->setAction($this->generateUrl('item_delete', ['item' => $item->getId()])) + ->getForm(); + + return $this->render('item/update.html.twig', [ + 'form' => $form, + 'item' => $item, + 'delete_form' => $deleteForm, + ]); + } +} +``` + +### DeleteController + +The delete form is validated to ensure the CSRF token is checked before dispatching. + +```php +createFormBuilder() + ->getForm(); + $deleteForm->handleRequest($request); + + if ($deleteForm->isSubmitted() && $deleteForm->isValid()) { + $this->messageBus->dispatch(new DeleteItemMessage($item)); + + $this->addFlash('success', $this->translator->trans('item.flash.deleted')); + } + + return $this->redirectToRoute('item_index'); + } +} +``` + +## Templates + +### index.html.twig + +Use a table when the entity has multiple columns worth showing; use cards otherwise. + +```twig +{% extends 'layout.html.twig' %} + +{% block header_navigation %} + + + {{ 'item.actions.create'|trans }} + +{% endblock %} + +{% block content %} + + + + + + + + + {% for item in items %} + + + + + {% else %} + + + + {% endfor %} + +
{{ 'item.label.name'|trans }}
{{ item.name }} + + + {{ 'item.actions.update'|trans }} + +
+
+ + {{ 'item.no_results'|trans }} +
+
+ + {% if items.hasToPaginate %} +
+ {{ pagination(items) }} +
+ {% endif %} +{% endblock %} +``` + +### create.html.twig + +Add each form field separately with `form_row`. + +```twig +{% extends 'layout.html.twig' %} + +{% block content %} + {{ form_start(form) }} + {{ form_row(form.name) }} + {{ form_end(form) }} +{% endblock %} + +{% block header_actions_right %} + +{% endblock %} +``` + +### update.html.twig + +The delete button uses the `confirm` Stimulus controller (see [stimulus.md](stimulus.md)) and is placed in +`header_actions_left` because it is a destructive action. + +```twig +{% extends 'layout.html.twig' %} + +{% block content %} + {{ form_start(form) }} + {{ form_row(form.name) }} + {{ form_end(form) }} +{% endblock %} + +{% block header_actions_left %} +
+ {{ form_start(delete_form, { attr: { 'data-confirm-target': 'element' }}) }} + + {{ form_end(delete_form) }} +
+{% endblock %} + +{% block header_actions_right %} + +{% endblock %} +``` + +## Testing + +### Message handler tests + +Handler tests are unit tests. Mock the repository and assert the correct method is called. + +```php +createMock(ItemRepository::class); + $repository->expects($this->once()) + ->method('save') + ->with($this->isInstanceOf(Item::class)); + + $message = new CreateItemMessage('Test item'); + + $handler = new CreateItemMessageHandler($repository); + $handler($message); + } +} +``` + +```php +createMock(ItemRepository::class); + $repository->method('find')->willReturn($item); + $repository->expects($this->once()) + ->method('save') + ->with($item); + + $message = new UpdateItemMessage($item); + $message->name = 'New name'; + + $handler = new UpdateItemMessageHandler($repository); + $handler($message); + + static::assertSame('New name', $item->getName()); + } +} +``` + +```php +createMock(ItemRepository::class); + $repository->method('find')->willReturn($item); + $repository->expects($this->once()) + ->method('remove') + ->with($item); + + $message = new DeleteItemMessage($item); + + $handler = new DeleteItemMessageHandler($repository); + $handler($message); + } + + public function testItThrowsWhenItemNotFound(): void + { + $repository = $this->createMock(ItemRepository::class); + $repository->method('find')->willReturn(null); + + $item = new Item('Test item'); + $message = new DeleteItemMessage($item); + + $this->expectException(ItemNotFoundException::class); + + $handler = new DeleteItemMessageHandler($repository); + $handler($message); + } +} +``` + +### Controller tests + +Controller tests are functional tests extending `WebTestCase`. They verify HTTP responses and redirects. + +```php +request('GET', '/admin/items'); + + static::assertResponseIsSuccessful(); + } +} +``` + +```php +request('GET', '/admin/items/create'); + + static::assertResponseIsSuccessful(); + } + + public function testItCreatesAnItemAndRedirects(): void + { + $client = static::createClient(); + $client->request('GET', '/admin/items/create'); + + $client->submitForm('item.actions.save', [ + 'item[name]' => 'Test item', + ]); + + static::assertResponseRedirects('/admin/items'); + } +} +``` + +```php +get(EntityManagerInterface::class); + $em->persist($item); + $em->flush(); + + $client->request('GET', '/admin/items/' . $item->getId() . '/update'); + + static::assertResponseIsSuccessful(); + } + + public function testItUpdatesAnItemAndRedirects(): void + { + $client = static::createClient(); + + $item = new Item('Original name'); + $em = static::getContainer()->get(EntityManagerInterface::class); + $em->persist($item); + $em->flush(); + + $client->request('GET', '/admin/items/' . $item->getId() . '/update'); + + $client->submitForm('item.actions.save', [ + 'item[name]' => 'Updated name', + ]); + + static::assertResponseRedirects('/admin/items'); + } +} +``` + +```php +get(EntityManagerInterface::class); + $em->persist($item); + $em->flush(); + + $client->request('GET', '/admin/items/' . $item->getId() . '/update'); + $client->submitForm('item.actions.delete'); + + static::assertResponseRedirects('/admin/items'); + } +} +``` + +## Translations + +```yaml +# translations/messages.en.yaml +item: + breadcrumb: + index: 'Items' + create: 'Add item' + label: + name: 'Name' + flash: + created: 'The item has been created.' + updated: 'The item has been saved.' + deleted: 'The item has been deleted.' + actions: + create: 'Add item' + update: 'Update' + save: 'Save' + delete: 'Delete' + delete: + confirm: 'Are you sure you want to delete "%item%"?' + no_results: 'No items found.' +``` diff --git a/docs/dark-mode.md b/docs/dark-mode.md new file mode 100644 index 00000000..c8fd144c --- /dev/null +++ b/docs/dark-mode.md @@ -0,0 +1,41 @@ +# Dark mode + +The bundle uses Bootstrap 5.3 color themes for dark mode. The active theme is stored in a cookie (`theme`) and applied +via the `data-bs-theme` attribute on ``. The Twig function `theme()` reads the cookie and returns the correct +class. + +Color variables are defined in `assets/scss/_bootstrap-variables-dark.scss`. See +the [Bootstrap color modes documentation](https://getbootstrap.com/docs/5.3/customize/color-modes/) for all available +CSS variables. + +## How it works + +1. `FrameworkExtension` (Twig) provides `theme()`, returns `'theme-light'` or `'theme-{cookieValue}'` +2. A Stimulus controller (`dark-mode`) toggles the cookie and updates `data-bs-theme` on `` without a page reload +3. `templates/partials/themetoggler.html.twig` renders the toggle button + +## Customize variables + +Override Bootstrap dark-mode CSS variables in `assets/scss/_bootstrap-variables-dark.scss`: + +```scss +// assets/scss/_bootstrap-variables-dark.scss +[data-bs-theme="dark"] { + --bs-body-bg: #1a1a2e; + --bs-body-color: #e0e0e0; +} +``` + +## Disable dark mode + +1. Set `$enable-dark-mode: false` in `assets/scss/_bootstrap-variables.scss` +2. Remove or comment out the dark mode logo and its `{% if %}` block in `templates/navigation.html.twig` +3. Remove `{% include 'partials/themetoggler.html.twig' %}` from your base layout + +## Troubleshooting + +- **Toggle button not visible**: check that `themetoggler.html.twig` is included in your base template +- **Theme resets on page reload**: the Stimulus controller writes a `theme` cookie; verify cookies are not blocked and + the domain matches +- **Dark variables not applying**: confirm `_bootstrap-variables-dark.scss` is imported after the Bootstrap variables + file in your main SCSS entry point diff --git a/docs/deployment/deployment.md b/docs/deployment/deployment.md deleted file mode 100644 index 96b7331c..00000000 --- a/docs/deployment/deployment.md +++ /dev/null @@ -1,52 +0,0 @@ -# Using deployment - -## Local alias - -You should configure an `alias`: - -``` -dep='symfony php vendor/bin/dep' -``` - -With this you can run `dep` instead of `symfony php vendor/bin/dep`. - -## Configuration - -You will need to fill in 4 variables: - -* `:client`, the `xxx` should be replaced with the name of the client. -* `:project`, the `xxx` should be replaced with the name of the project. -* `:repository`, the `xxx` should be replaced with the url of the git-repo. -* `:production_url`, the `xxx` should be replaced with the production url. -* `:production_user`, the `xxx` should be replaced with the production user. - -Remark: when choosing a name for the project, please don't use generic names -as: site, app, ... as it makes no sense when there are multiple projects for -that client. - -## Deploy commands - -List all possible deploy commands - - dep list - -## Deploy - - dep deploy stage=staging - -## Deploy images and files - -In the deploy.php script we can set the shared directory. -This is how the local and remote server will be able to download or upload the correct directory. - - Example: add('shared_dirs', ['public/files']); - -Please keep in mind that when executing the get or put files command that the files are being replaced. - -Get all files from remote server to local - - dep sumo:files:get - -Put all files from local to remote server - - dep sumo:files:put diff --git a/docs/development/autocomplete.md b/docs/development/autocomplete.md deleted file mode 100644 index d7cdf000..00000000 --- a/docs/development/autocomplete.md +++ /dev/null @@ -1,74 +0,0 @@ -# Autocomplete - -The framework core makes use of `symfony/ux-autocomplete` to provide autocomplete functionality. - -## Usage - -To enable autocomplete functionality, you need to add the `autocomplete` parameter to your form field. All other options -are optional. - -```php -class AnyForm extends AbstractType -{ - public function buildForm(FormBuilderInterface $builder, array $options): void - { - $builder - ->add('food', EntityType::class, [ - 'class' => Food::class, - 'placeholder' => 'What should we eat?', - 'autocomplete' => true, - ]) - - ->add('portionSize', ChoiceType::class, [ - 'choices' => [ - 'Choose a portion size' => '', - 'small' => 's', - 'medium' => 'm', - 'large' => 'l', - 'extra large' => 'xl', - 'all you can eat' => '∞', - ], - 'autocomplete' => true, - ]) - ; - } -} -``` - -## Using Ajax - -If you want to use ajax to fetch the autocomplete options, you need to create a new form type with the -`AsEntityAutocompleteFieldz` attribute. - -```php -use Symfony\Component\Security\Core\Security; -use Symfony\UX\Autocomplete\Form\AsEntityAutocompleteField; -use Symfony\UX\Autocomplete\Form\BaseEntityAutocompleteType; - -#[AsEntityAutocompleteField] -class FoodAutocompleteField extends AbstractType -{ - public function configureOptions(OptionsResolver $resolver) - { - $resolver->setDefaults([ - 'class' => Food::class, - 'placeholder' => 'What should we eat?', - - // choose which fields to use in the search - // if not passed, *all* fields are used - //'searchable_fields' => ['name'], - - // if the autocomplete endpoint needs to be secured - //'security' => 'ROLE_FOOD_ADMIN', - - // ... any other normal EntityType options - // e.g. query_builder, choice_label - ]); - } - - public function getParent(): string - { - return BaseEntityAutocompleteType::class; - } -} -``` diff --git a/docs/development/encrypted.md b/docs/development/encrypted.md deleted file mode 100644 index 2201ed55..00000000 --- a/docs/development/encrypted.md +++ /dev/null @@ -1,14 +0,0 @@ -# Encrypted strings in the database - -The core bundle has a DBAL type which encrypts strings with the built-in PHP libsodium functions. To use it, simply -apply the type to a string in your entity. - -Example: - -```php - /** - * @ORM\Column(type="encrypted") - */ - private string $encryptedString; -``` - diff --git a/docs/development/fixtures.md b/docs/development/fixtures.md deleted file mode 100644 index da4665df..00000000 --- a/docs/development/fixtures.md +++ /dev/null @@ -1,4 +0,0 @@ -# Using fixtures - -Fixtures in the framework are handled by -the [DoctrineFixturesBundle](http://symfony.com/doc/current/bundles/DoctrineFixturesBundle/index.html). diff --git a/docs/development/forms.md b/docs/development/forms.md deleted file mode 100644 index 972325db..00000000 --- a/docs/development/forms.md +++ /dev/null @@ -1,46 +0,0 @@ -# Forms - -## Translations - -The label for a field will be automatically translated. - -```php - ... - ->add( - 'username', - TextType::class, - ) - ... -``` - -Will result in - -```html - -
- - -
-``` -Where `Username` is a translation. So if you want to translate the label, you can do it like this: - -```yaml -Username: 'Enter your username' -``` - -## Belgium postcode - -Form type that allows only valid belgian postcodes to be selected. - -Use in your own form: -```php - $builder->add('postcode', BelgiumPostCodeType::class); -``` - -Add a property in the DTO: -```php - #[Assert\NotBlank] - public ?BelgiumPostCode $postcode = null; -``` diff --git a/docs/development/language-switch.md b/docs/development/language-switch.md deleted file mode 100644 index d24fc5e3..00000000 --- a/docs/development/language-switch.md +++ /dev/null @@ -1,32 +0,0 @@ -# Adding a new language - -- Extend the locales parameter in config/services.yaml - -# Language switch - -You can add a language switch to navigation.html.twig by using the following snippet: - -``` - - -``` - -Place it between the logo and user menu dropdown. diff --git a/docs/development/menu.md b/docs/development/menu.md deleted file mode 100644 index a981fe4f..00000000 --- a/docs/development/menu.md +++ /dev/null @@ -1,122 +0,0 @@ -# Adding items into the menu/navigation - -To create a menu & add items to it, you'll need to set up an event listener that listens to the -`framework_core.configure_menu` event. - -In short, you'll need to add the following: - -* In src/EventListener, create a file called MenuListener just like the example below. -* In config/services.yaml, add the following configuration snippet: - -```yml -services: - App\EventListener\MenuListener: - tags: - - { name: kernel.event_listener, event: framework_core.configure_menu, method: onConfigureMenu } -``` - -To make things easier, there's a DefaultMenuListener to extend your MenuListener from. This base class already has three -autowired arguments: - -* TranslatorInterface -* Security -* RequestStack - -You can use them like so: - -* `$this->getTranslator()->trans('some text')` to translate stuff -* `$this->getSecurity()->isGranted('ROLE_ADMIN');` to check for roles -* `$this->getRequestStack()->getCurrentRequest()->...` to access the current request. - -There is also a helper called `enableChildRoutes`, which takes a prefix string as an argument. Calling this method on a -menu item, will activate it when a route is visited that starts with the prefix you pass. - -In short, if you have a menu item with `user_admin_overview` as the route, and you enable child routes with the `user_admin_` -prefix, all the following routes will also mark the user menu item as active: - -* `user_admin_add` -* `user_admin_edit` -* `user_admin_whatever` - -## The example listener - -```php -getFactory(); - $menu = $event->getMenu(); - - if ($this->getSecurity()->isGranted("ROLE_ADMIN")) { - $menu->addChild( - $factory->createItem( - $this->getTranslator()->trans('Users''), - [ - 'route' => 'user_admin_overview', - 'labelAttributes' => [ - 'icon' => 'bi bi-person-fill', - ], - 'extras' => [ - 'routes' => [ - 'user_admin_add', - 'user_admin_edit', - ], - ], - ], - ) - ); - } - } - - /** - * @return array - */ - public static function getSubscribedEvents(): array - { - return [ConfigureMenuEvent::EVENT_NAME => 'onConfigureMenu']; - } -} - -``` - -# Nested menu items - -To create a dropdown menu with child items, create a parent item with `uri => #` instead of a route, and call addChild on it before adding it to the menu. - -## Example nested menu item - -```php -$paymentsMenuItem = $factory->createItem( - $this->getTranslator()->trans('Payments'), - [ - 'uri' => '#', - 'labelAttributes' => [ - 'icon' => 'fa-regular fa-credit-card', - ], - ], -); - -$paymentsMenuItem->addChild( - $factory->createItem( - $this->getTranslator()->trans('Overview'), - [ - 'route' => 'payments_overview', - 'labelAttributes' => [ - 'icon' => 'fa-solid fa-money-bill', - ], - ], - ) -); - -$menu->addChild($paymentsMenuItem); - -``` diff --git a/docs/development/migrations.md b/docs/development/migrations.md deleted file mode 100644 index 8705202a..00000000 --- a/docs/development/migrations.md +++ /dev/null @@ -1,24 +0,0 @@ -# Changing the database - -While developing you sometimes need to change the database. But to be able to -change the database on the server while deploying instead of doing these -changes manually we will use migrations. - -Migrations in the framework are handled by -the [DoctrineMigrationsBundle](http://symfony.com/doc/current/bundles/DoctrineMigrationsBundle/index.html). - -## Usage - -Basically it is simple. The only thing you have to do when you need to change -the database structure is to change the annotation/configuration/... and run: - - bin/console doctrine:migrations:diff - -This will generate a migration-class which you can commit along your changes. -Of course you will need to run the migration to update your own database: - - bin/console doctrine:migration:migrate - -This is only the basic usage but there are more options in -the [official doctrine documentation]((http://symfony.com/doc/current/bundles/DoctrineMigrationsBundle/index.html)). -It is possible to execute migrations up and down or create custom migration files. diff --git a/docs/development/pagination.md b/docs/development/pagination.md deleted file mode 100644 index b2ff1e2c..00000000 --- a/docs/development/pagination.md +++ /dev/null @@ -1,144 +0,0 @@ -# Using pagination - -Pagination is a nice way to handle large amounts of data over multiple pages. The core bundle has a Paginator class (similar to Pagerfanta), that does most of the heavy lifting. - -## Usage -Define the Paginator object in your repository, where you pass the QueryBuilder object straight to it. -### Repository - -```php - use SumoCoders\FrameworkCoreBundle\Pagination\Paginator; - - public function getPaginatedItems(): Paginator - { - $queryBuilder = $this->createQueryBuilder('i') - ->where('i.name LIKE :term') - ->setParameter('term', 'foo') - ->orderBy('i.name'); - - return new Paginator($queryBuilder); - } -``` -## Controller - -In your controller, use the `paginate` method on it to set the correct page. You can also extend this with sorting GET parameters that you pass to your method in the repository. Since the pagination works on a QueryBuilder object, al sorting must be done with orderBy's. - -```php -getPaginatedItems(); - - $paginatedItems->paginate($request->query->getInt('page', 1)); - - return $this->render('items/index.html.twig', [ - 'items' => $paginatedItems, - ]); - } -} -``` - -### Filters - -In a lot of projects, you'll have to integrate the pagination with some sort of filter/search. - -To do so, create a form, render it on the overview page where you have the pagination, and pass the form values to your repository where you can use them in your querybuilder. - -In your controller, this would look something like: -```php -$form = $this->createForm( - FilterType::class, - new FilterDataTransferObject() -); - -$form->handleRequest($request); - -$paginatedUsers = $userRepository->getAllFilteredUsers($form->getData()); - -$paginatedUsers->paginate($request->query->getInt('page', 1)); -``` -with the following matching method in the repository: -```php -public function getAllFilteredUsers(FilterDataTransferObject $filter): Paginator -{ - $queryBuilder = $this->createQueryBuilder('u'); - - if (isset($filter->term) && $filter->term !== null) { - $queryBuilder - ->where('u.email LIKE :term') - ->setParameter('term', '%' . $filter->term . '%'); - } - - return new Paginator($queryBuilder); -} -``` -Note that this approach will **not perists across page requests**, since the form will POST again (with empty values) when you move to the next page. If we want to keep the result set, we'll have to provide the entered data back to the form. The easiest way to do this is to use the session. -```php -// If a filter is active, use it -if ($request->getSession()->has('user_filter')) { - $userFilterFormData = unserialize($request->getSession()->get('user_filter')); -} else { -// If not, create a blank one - $userFilterFormData = new FilterDataTransferObject(); -} - -$form = $this->createForm( - FilterType::class, - $userFilterFormData -); - -$form->handleRequest($request); - -if ($form->isSubmitted() && $form->isValid()) { -// If a filter form is submitted, store the values in the session - $request->getSession()->set('vegetation_filter', serialize($form->getData())); -} - -/* - * The user can clear the filter by submitting the form with a blank value - * or you could provide a way for him to "clear the filter", where you - * remove the session variable - */ -$paginatedUsers = $userRepository->getAllFilteredUsers($form->getData()); - -$paginatedUsers->paginate($request->query->getInt('page', 1)); -``` - - -## Template - -In your template, you have access to a Twig extension called `pagination` to render a clean pagination widget. - -The paginated object, in this case `items` is an iterator, so you can count it/loop over it to get the results of the query. - -```twig -{% if items|length > 0 %} - {% for item in items %} -
    -
  • {{ item.id }}
  • -
- {% endfor %} -{% endif %} - -{% if items.hasToPaginate %} -
- {{ pagination(items) }} -
-{% endif %} -``` diff --git a/docs/development/pdf.md b/docs/development/pdf.md deleted file mode 100644 index ac3c8f12..00000000 --- a/docs/development/pdf.md +++ /dev/null @@ -1,3 +0,0 @@ -# Creating PDFs - -You can render pdfs with [dreadnip/chrome-pdf-bundle](https://github.com/sanderdlm/chrome-pdf-bundle). \ No newline at end of file diff --git a/docs/development/uploading-files.md b/docs/development/uploading-files.md deleted file mode 100644 index e4af906c..00000000 --- a/docs/development/uploading-files.md +++ /dev/null @@ -1,233 +0,0 @@ -# Uploading files - -You can find a base value object that you can use to upload files. - -It can be used in combination with the form type `SumoCoders\FrameworkCoreBundle\Form\Type\FileType` - -While most of the things you need to do are already written for you, you will still need to add some configuration for -each implementation. - -## Basic implementation - -### Create a value object - -Not all files are created equal. The file you want to upload has a specific meaning in your application and therefor -your implementation should reflect that. - -* Create a new class -* Extend the class `SumoCoders\FrameworkCoreBundle\ValueObject\AbstractFile` -* Implement the `getUploadDir()` method (for documentation about this see the phpdoc) - -After implementing this your value object will be transformed into the web path of your file when it is sent to the -template. - -This way you can just use it like `myEntity.myFile` - -#### Example - -```php -cv; - } - - /** - * @param CV $cv - * @return self - */ - public function setCv($cv) - { - $this->cv = $cv; - - return $this; - } - - /** - * @ORM\PreUpdate() - * @ORM\PrePersist() - */ - public function prepareToUploadCV() - { - $this->cv->prepareToUpload(); - } - - /** - * @ORM\PostUpdate() - * @ORM\PostPersist() - */ - public function uploadCV() - { - $this->cv->upload(); - } - - /** - * @ORM\PostRemove() - */ - public function removeCV() - { - $this->cv->remove(); - } -} -``` - -### Update your form type - -For the last step you need to add your file to your form. - -* Use `SumoCoders\FrameworkCoreBundle\Form\Type\FileType` as the form type -* Set the fully qualified class name (FQCN) of your value object in the option `file_class` (tip: you can use - `MyFile::class` for that) - -#### Example - -```php -add('cv', FileType::class, ['file_class' => CV::class]); - } -} -``` - -## Extra configuration options - -To make your life even easier, the form FileType has some interesting configuration options on top of the default -options that the Symfony FileType already has. - -* `show_preview`: By default we will show a link to view the current file if there is one. You can disable this using - this option. -* `preview_label`: You can use it to change the translation label that will be in the link to view your current file. -* `show_remove_file`: If your file is not required we will automatically add the option for the user to remove the file, - You can disable this using this option. -* `remove_file_label`: You can use it to change the translation label of the remove file checkbox. diff --git a/docs/development/uploading-images.md b/docs/development/uploading-images.md deleted file mode 100644 index 0ab2c0c0..00000000 --- a/docs/development/uploading-images.md +++ /dev/null @@ -1,237 +0,0 @@ -# Uploading images - -You can find a base value object that you can use to upload images. - -It can be used in combination with the form type `SumoCoders\FrameworkCoreBundle\Form\Type\ImageType` - -While most of the things you need to do are already written for you, you will still need to add some configuration for -each implementation. - -## Basic implementation - -### Create a value object - -Not all images are created equal. The image you want to upload has a specific meaning in your application and therefor -your implementation should reflect that. - -* Create a new class -* Extend the class `SumoCoders\FrameworkCoreBundle\ValueObject\AbstractImage` -* Implement the `getUploadDir()` method (for documentation about this see the phpdoc) -* If you want a fallback image you can overwrite the constant `FALLBACK_IMAGE` - -After implementing this your value object will be transformed into the web path of your file when it is sent to the -template. - -This way you can just use it like `myEntity.myImage` - -#### Example - -```php -avatar; - } - - /** - * @param Avatar $avatar - * @return self - */ - public function setAvatar($avatar) - { - $this->avatar = $avatar; - - return $this; - } - - /** - * @ORM\PreUpdate() - * @ORM\PrePersist() - */ - public function prepareToUploadAvatar() - { - $this->avatar->prepareToUpload(); - } - - /** - * @ORM\PostUpdate() - * @ORM\PostPersist() - */ - public function uploadAvatar() - { - $this->avatar->upload(); - } - - /** - * @ORM\PostRemove() - */ - public function removeAvatar() - { - $this->avatar->remove(); - } -} -``` - -### Update your form type - -For the last step you need to add your file to your form. - -* Use `SumoCoders\FrameworkCoreBundle\Form\Type\ImageType` as the form type -* Set the fully qualified class name (FQCN) of your value object in the option `image_class` (tip: you can use - `MyImage::class` for that) - -#### Example - -```php -add('avatar', ImageType::class, ['image_class' => Avatar::class]); - } -} -``` - -## Extra configuration options - -To make your life even easier, the form FileType has some interesting configuration options on top of the default -options that the Symfony FileType already has. - -* `show_preview`: By default we will show a preview of the current image if there is one. You can disable this using - this option. -* `preview_class`: You can use this option to add an extra class to the preview image, for example you could add - `img-circle` to make the preview image round -* `show_remove_image`: If your image is not required we will automatically add the option for the user to remove the - image, You can disable this using this option. -* `remove_image_label`: You can use it to change the translation label of the remove image checkbox. diff --git a/docs/development/using-date-pickers.md b/docs/development/using-date-pickers.md deleted file mode 100644 index f8e2daf1..00000000 --- a/docs/development/using-date-pickers.md +++ /dev/null @@ -1,68 +0,0 @@ -# Using date pickers - -While the native date and time pickers have improved over the years, we still had some edge cases to cover, so we -chose [Flatpickr](https://flatpickr.js.org/) to provide a beautiful, no-dependency datetime picker in the framework. - -You don't have to pass any option or attribute, the picker will always show when you use any of the following types: - -* DateType -* TimeType -* DateTimeType -* BirthdayType - -```php -add( - 'date', - DateType::class, - [ - 'data' => new DateTime(), - 'minimum_date' => (new \DateTimeImmutable('last week'))->format('d/m/Y'), - 'maximum_date' => (new \DateTimeImmutable('next week'))->format('d/m/Y'), - ] - ); -``` - -For the picker to work, the `widget` option has to be set to `single_text`. This is the default, so as long as you leave -it, you should be fine. - -## Options - -There are two option helpers to set date ranges: - -* `minimum_date`: takes a formatted string. Will be set as the `min` value on the Flatpickr instance. -* `maximum_date`: takes a formatted string. Will be set as the `max` value on the Flatpickr instance. - -All other options you'll have to set yourself. You can find the full list of options in -the [Flatpickr documentation](https://flatpickr.js.org/options/). - -The easiest way to pass options is to set them as data attributes on the form field. To do this, you'll have to -transform the option name from camel to snake case. - -Example: - -* `minDate` becomes `min-date` -* `showMonths` becomes `show-months` - -All Flatpickr data atributes must be prefixed with `date`. A full, working example would be: - -```php -add( - 'date', - DateType::class, - [ - 'data' => new DateTime(), - 'minimum_date' => (new \DateTimeImmutable('last week'))->format('d/m/Y'), - 'maximum_date' => (new \DateTimeImmutable('next week'))->format('d/m/Y'), - 'attr' [ - 'data-date-min-date' => '01/01/1993', - 'data-date-show-months' => false, - ] - ] - ); -``` \ No newline at end of file diff --git a/docs/encrypted.md b/docs/encrypted.md new file mode 100644 index 00000000..2b173a5c --- /dev/null +++ b/docs/encrypted.md @@ -0,0 +1,93 @@ +# Encrypted fields + +Transparently encrypts and decrypts a Doctrine column using libsodium (`sodium_crypto_secretbox`). The value is stored +as `TEXT` in the database; PHP reads and writes a plain string. Encryption is field-level, the rest of the entity is +not affected. + +## Prerequisites + +- PHP with the `sodium` extension (bundled since PHP 7.2) +- `ENCRYPTION_KEY` set in `.env.local`: a 64-character hex string (32 bytes) + +Generate a key: + +```bash +php -r "echo sodium_bin2hex(random_bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES)) . PHP_EOL;" +``` + +Add to `.env.local`: + +```dotenv +ENCRYPTION_KEY=your_64_char_hex_string_here +``` + +## Usage + +Register the DBAL type in `config/packages/doctrine.yaml`: + +```yaml +doctrine: + dbal: + types: + encrypted: SumoCoders\FrameworkCoreBundle\DBALType\EncryptedDBALType +``` + +Use the `encrypted` type on any string property: + +```php +socialSecurityNumber; + } + + public function setSocialSecurityNumber(?string $value): void + { + $this->socialSecurityNumber = $value; + } +} +``` + +## How it works + +| Direction | Operation | +|------------------|---------------------------------------------------------------------| +| Write (PHP → DB) | `sodium_crypto_secretbox` encrypts the value; stored as `hex(nonce)|hex(ciphertext)` | +| Read (DB → PHP) | Splits on `|`, decrypts with `sodium_crypto_secretbox_open`, returns plain string | + +The nonce is randomly generated per write, so the same plaintext produces a different ciphertext each time. + +## Limitations + +- **Not searchable**: encrypted values cannot be used in `WHERE` clauses or indexes. Filter in PHP after fetching. +- **Type is always `TEXT`**: column length constraints have no effect. +- **String only**: the type stores and returns a string. Cast integers, dates, etc. in your entity getter/setter. +- **No key rotation built in**: changing `ENCRYPTION_KEY` requires re-encrypting all rows manually. + +## Migrations + +When adding an encrypted column to an existing table with data: + +1. Add the nullable `encrypted` column +2. In a separate migration, read and re-save each row through the entity manager so the DBAL type encrypts the values +3. Apply any `NOT NULL` constraint in a third migration after the backfill + +## Troubleshooting + +- **`RuntimeException: ENCRYPTION_KEY should be a valid 64 character key`**: the env var is missing or not loaded. + Check `.env.local` and restart the dev server. +- **`ConversionException` on read**: the stored value was encrypted with a different key, or the column contains a + plain-text legacy value. Decrypt/migrate those rows before switching keys. +- **Column shows `(Encrypted)` comment in the database**: expected; the DBAL type sets that as the column comment + automatically. diff --git a/docs/forms.md b/docs/forms.md new file mode 100644 index 00000000..b853f7d7 --- /dev/null +++ b/docs/forms.md @@ -0,0 +1,190 @@ +# Forms + +The bundle provides custom form types for images, files, and Belgium postcodes, plus type extensions that enhance the +built-in Symfony form types. + +--- + +## Custom form types + +### ImageType + +Renders a file input for an `AbstractImage` subclass with an optional preview and a remove checkbox. The form field +expects the entity property to be typed as your `AbstractImage` subclass. + +See [uploading-images.md](uploading-images.md) for creating the required `AbstractImage` subclass. + +```php +add('photo', ImageType::class, [ + 'image_class' => UserPhoto::class, + 'label' => 'forms.labels.photo', + 'help' => 'forms.help.photo', + 'accept' => 'image/jpeg,image/png,image/webp', + 'show_preview' => true, + 'show_remove_image' => true, + 'remove_image_label' => 'forms.labels.removeImage', + 'required_image_error' => 'forms.not_blank', +]); +``` + +**Options:** + +| Option | Type | Default | Required | Description | +|------------------------|----------|------------------------------|----------|-----------------------------------------------------| +| `image_class` | `string` | | yes | FQCN of your `AbstractImage` subclass | +| `label` | `string` | | yes | Form field label | +| `help` | `string` | | yes | Help text below the field | +| `accept` | `string` | `'image/*'` | yes | Accepted MIME types for the file input | +| `show_preview` | `bool` | `true` | yes | Show current image as a preview | +| `show_remove_image` | `bool` | `true` | yes | Show a checkbox to delete the current image | +| `remove_image_label` | `string` | `'forms.labels.removeImage'` | yes | Label for the remove checkbox | +| `required_image_error` | `string` | `'forms.not_blank'` | yes | Validation message when a required image is missing | + +--- + +### FileType + +Same as `ImageType` but for `AbstractFile` subclasses (any file type). + +See [uploading-files.md](uploading-files.md) for creating the required `AbstractFile` subclass. + +```php +add('document', FileType::class, [ + 'file_class' => UserDocument::class, + 'label' => 'forms.labels.document', + 'help' => 'forms.help.document', + 'accept' => 'application/pdf', + 'show_preview' => true, + 'preview_label' => 'forms.labels.viewCurrentFile', + 'show_remove_file' => true, + 'remove_file_label' => 'forms.labels.removeFile', + 'required_file_error' => 'forms.not_blank', +]); +``` + +**Options:** + +| Option | Type | Default | Required | Description | +|-----------------------|----------------|----------------------------------|----------|-------------------------------------------------------| +| `file_class` | `string` | | yes | FQCN of your `AbstractFile` subclass | +| `label` | `string` | | yes | Form field label | +| `help` | `string` | | yes | Help text below the field | +| `accept` | `string\|null` | `null` | yes | Accepted MIME types for the file input (`null` = any) | +| `show_preview` | `bool` | `true` | yes | Show a link to the current file | +| `preview_label` | `string` | `'forms.labels.viewCurrentFile'` | yes | Link text for the current file preview | +| `show_remove_file` | `bool` | `true` | yes | Show a checkbox to delete the current file | +| `remove_file_label` | `string` | `'forms.labels.removeFile'` | yes | Label for the remove checkbox | +| `required_file_error` | `string` | `'forms.not_blank'` | yes | Validation message when a required file is missing | + +--- + +### BelgiumPostCodeType + +A select field restricted to valid Belgian postcodes. Internally uses a predefined list of all Belgian postcodes. + +```php +add('postcode', BelgiumPostCodeType::class); +``` + +Add a `BelgiumPostCode` property to your DTO: + +```php +add('contacts', CollectionType::class, [ + 'entry_type' => ContactType::class, + 'allow_add' => true, + 'allow_delete' => true, + 'minimum_required_items' => 1, + 'maximum_required_items' => 5, +]); +``` + +### PasswordType + +Adds a show/hide toggle button to `PasswordType` fields. + +**Added options:** + +| Option | Type | Default | Description | +|----------------------------|----------------|---------------------------------|----------------------------------------| +| `toggle` | `bool` | `true` | Enable the show/hide toggle | +| `hidden_label` | `string\|null` | `'Hide password'` | Tooltip/label when password is visible | +| `visible_label` | `string\|null` | `'Show password'` | Tooltip/label when password is hidden | +| `button_classes` | `string[]` | `['toggle-password-button']` | CSS classes on the toggle button | +| `toggle_container_classes` | `string[]` | `['toggle-password-container']` | CSS classes on the wrapper element | + +To disable the toggle on a specific field: + +```php +$builder->add('apiKey', PasswordType::class, [ + 'toggle' => false, +]); +``` + +--- + +## Translations + +Form field labels are translated automatically using the form's translation domain. The label key is derived from the +field name (e.g. field `username` → key `Username`). + +To provide a custom translation, add a key to your translation file: + +```yaml +# translations/messages.en.yaml +Username: 'Enter your username' +``` + +No change to the form builder is needed. diff --git a/docs/frontend/frontend-development.md b/docs/frontend-development.md similarity index 63% rename from docs/frontend/frontend-development.md rename to docs/frontend-development.md index 22fa8821..96af1bbe 100644 --- a/docs/frontend/frontend-development.md +++ b/docs/frontend-development.md @@ -1,21 +1,28 @@ # Frontend development ## Quick start -We use Symfony AssetMapper to manage our assets. See the [documentation](https://symfony.com/doc/current/frontend/asset_mapper.html) for more information. + +We use Symfony AssetMapper to manage our assets. See +the [documentation](https://symfony.com/doc/current/frontend/asset_mapper.html) for more information. + - Install assets with `symfony console importmap:install` - Build assets with `symfony console asset-map:compile` ## Sass + All Sass sources are included in `framework-core-bundle` bundle under `assets/scss/`. While developing, you can run `symfony console sass:build --watch` to automatically compile on change. ### Overriding Bootstrap variables -Use `assets/scss/_bootstrap-variables.scss` to override Bootstrap variables. Import this file in `assets/scss/style.scss` before the Bootstrap imports so your overrides take effect. -Use Bootstrap variables as much as possible to customize styling — this makes the code easier to maintain. +Use `assets/scss/_bootstrap-variables.scss` to override Bootstrap variables. Import this file in +`assets/scss/style.scss` before the Bootstrap imports so your overrides take effect. + +Use Bootstrap variables as much as possible to customize styling. This makes the code easier to maintain. #### Variables + - `$top-color` sets the top navbar background color - `$menu-bg` sets the sidebar background color - `$menu-color` sets the sidebar text color @@ -25,11 +32,15 @@ Use Bootstrap variables as much as possible to customize styling — this makes - `$secondary` sets the secondary color used in buttons, links, etc. ### Dark mode + We use Bootstrap’s dark mode implementation. See the [documentation](https://getbootstrap.com/docs/5.3/customize/color-modes/). -Most importantly, there are no separate dark mode files or stylesheets. Use `@include color-mode(dark) { ... }` to add dark mode styles. This mixin cannot be nested; put all dark mode styles in a single block at the end of the relevant SCSS file. +Most importantly, there are no separate dark mode files or stylesheets. Use `@include color-mode(dark) { ... }` to add +dark mode styles. This mixin cannot be nested; put all dark mode styles in a single block at the end of the relevant +SCSS file. #### Variables + - `$top-color-dark` sets the dark mode top navbar background color - `$menu-bg-dark` sets the dark mode sidebar background color - `$menu-color-dark` sets dark mode the sidebar text color @@ -37,8 +48,9 @@ Most importantly, there are no separate dark mode files or stylesheets. Use `@in - `$menu-active-color-dark` sets dark mode the sidebar active text color ### Custom components or extensions (Sass) -Place custom SCSS components in `assets/scss/components/`. Import your components after the Bootstrap imports. Try to base your custom components on Bootstrap components as much as possible. -You can find frequently used components in the documentation file [components.html](https://github.com/sumocoders/FrameworkCoreBundle/blob/master/docs/frontend/components.html). + +Place custom SCSS components in `assets/scss/components/`. Import your components after the Bootstrap imports. Try to +base your custom components on Bootstrap components as much as possible. ### Folder overview @@ -54,20 +66,27 @@ You can find frequently used components in the documentation file [components.ht ## JavaScript -Most JavaScript in this bundle is provided as Stimulus controllers under `assets-public/controllers/` and utility modules under `assets-public/js/`. In your application, import the controllers in `assets/bootstrap.js`. Your `assets/app.js` typically acts as the main JavaScript entry. +Most JavaScript in this bundle is provided as Stimulus controllers under `assets-public/controllers/` and utility +modules under `assets-public/js/`. In your application, import the controllers in `assets/bootstrap.js`. Your +`assets/app.js` typically acts as the main JavaScript entry. ### Custom components or extensions (JavaScript) -Create a `components/` folder under your app’s JavaScript directory. Add your new component module (ES6 class/module) there and import it in `app.js`. -If you want to extend or replace an existing module, update the import in `bootstrap.js` to point to your new class. The new class can be completely different or extend a class from the existing assets. +Create a `components/` folder under your app’s JavaScript directory. Add your new component module (ES6 class/module) +there and import it in `app.js`. + +If you want to extend or replace an existing module, update the import in `bootstrap.js` to point to your new class. The +new class can be completely different or extend a class from the existing assets. ## Separate layout for the application’s frontend For a separate public/frontend layout, use a Bootstrap 5 setup. -Create a file `style-frontend.scss` in `assets/scss/`. You can import Bootstrap as explained in the Bootstrap [documentation](https://getbootstrap.com/docs/5.3/customize/sass/#importing). +Create a file `style-frontend.scss` in `assets/scss/`. You can import Bootstrap as explained in the +Bootstrap [documentation](https://getbootstrap.com/docs/5.3/customize/sass/#importing). -Don’t forget to add a new entry in `config/packages/symfonycasts_sass.yaml` for `style-frontend.scss` and include that entry in your frontend templates. +Don’t forget to add a new entry in `config/packages/symfonycasts_sass.yaml` for `style-frontend.scss` and include that +entry in your frontend templates. #### Import Bootstrap yourself @@ -77,11 +96,14 @@ Don’t forget to add a new entry in `config/packages/symfonycasts_sass.yaml` fo - Create `bootstrap-imports.scss` in the `frontend/` folder for the Bootstrap imports. - Import your variables file before the Bootstrap imports. - Import your components after the Bootstrap imports. -- You can copy starter files from `vendor/sumocoders/framework-core-bundle/assets/scss/` into your new `frontend/` folder. Update variables as needed or copy them from the original Bootstrap `_variables.scss` (found in `vendor/twbs/bootstrap/scss/`). If you copy these, remove the `!default` modifiers so your overrides take effect. +- You can copy starter files from `vendor/sumocoders/framework-core-bundle/assets/scss/` into your new `frontend/` + folder. Update variables as needed or copy them from the original Bootstrap `_variables.scss` (found in + `vendor/twbs/bootstrap/scss/`). If you copy these, remove the `!default` modifiers so your overrides take effect. ### Separate JavaScript -This works the same way as Sass. Create a new entry and a new collector file (for example: `app-frontend.js`) in a separate frontend JavaScript folder in your app. Load the correct entry in your frontend templates. +This works the same way as Sass. Create a new entry and a new collector file (for example: `app-frontend.js`) in a +separate frontend JavaScript folder in your app. Load the correct entry in your frontend templates. ## Upgrading from separate dark mode stylesheet diff --git a/docs/frontend/components.html b/docs/frontend/components.html deleted file mode 100644 index 0ec2ab52..00000000 --- a/docs/frontend/components.html +++ /dev/null @@ -1,234 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-

Lorem ipsum

-
-
-
-

Lorem ipsum dolor sit amet

- - -
-
...
-
...
-
...
-
...
-
- - - - - - - - - - -
......
......
-
-
- - - - - - - - -
- - - - - - - - - - - - -
...
...
-
- - - -
- - {{ 'filter.empty.users'|trans }} -
- - - -Primary - - - - - -
- - {{ 'quotation.empty.items'|trans }} -
- - -
- - -
-
-
-
Card title
-

- Card body text -

-
- -
-
- - -
-
-
-
{{ dossier.id }}
- {% if dossier.trader and dossier.trader.traderContact %} -

- {{ dossier.trader.traderContact.firstName }} {{ dossier.trader.traderContact.lastName }} -

- {% endif %} -
- -
-
- - -
-
- -
-

Card title

-
-
-
-
-
{{ 'Location'|trans }}:
-
AB | Brussel
-
-
-
{{ 'Dates'|trans }}:
-
18/08
-
-
-
{{ 'Genre'|trans }}:
-
Rock
-
-
- -
-
-
diff --git a/docs/frontend/dark-mode.md b/docs/frontend/dark-mode.md deleted file mode 100644 index 2888a105..00000000 --- a/docs/frontend/dark-mode.md +++ /dev/null @@ -1,11 +0,0 @@ -# Dark mode - -We use bootstrap color themes for dark mode styling. See the [documentation](https://getbootstrap.com/docs/5.3/customize/color-modes/) for more information. -Color variables are defined in `assets/scss/_bootstrap-variables-dark.scss`. - -## Disable dark mode - -Set `$enable-dark-mode` to false in `assets/scss/_bootstrap-variables.scss` to disable dark mode completely. - -- Remove dark mode logo and if statements on the light mode logo in `templates/navigation.html.twig` -- Hide or remove themetoggler.html.twig from your base layout diff --git a/docs/frontend/installation.md b/docs/frontend/installation.md deleted file mode 100644 index 5752a42b..00000000 --- a/docs/frontend/installation.md +++ /dev/null @@ -1,6 +0,0 @@ -# Installation - -1. `git clone ` -2. `symfony composer install` -4. `npm install` -5. `npm run build` diff --git a/docs/frontend/mails.md b/docs/frontend/mails.md deleted file mode 100644 index 8ac6cde0..00000000 --- a/docs/frontend/mails.md +++ /dev/null @@ -1,11 +0,0 @@ -# Mail stijling - -We use Foundation, so consult the [docs](https://get.foundation/emails/docs/global.html) to see -what your options are regarding components, helpers, ... - -## Logo - -We use a logo with fixed width (182px) that is set on the img-tag in the mail template. This is because some -mail clients can't handle the size of an image right. We also use `.gif` extension for the same reason. - -You can change the width of an image, but than you have to update the width attribute on the img-tag too. diff --git a/docs/frontend/no-results.md b/docs/frontend/no-results.md deleted file mode 100644 index f4840306..00000000 --- a/docs/frontend/no-results.md +++ /dev/null @@ -1,10 +0,0 @@ -# No results in datagrid / no data on page - -Use the following 'no results' snippet when there are no results or data in a (filtered) datagrid or on a page. - -``` -
- - {{ 'quotation.empty.items'|trans }} (replace with correct translation) -
-``` diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..069eda3a --- /dev/null +++ b/docs/index.md @@ -0,0 +1,92 @@ +# FrameworkCoreBundle Documentation + +`FrameworkCoreBundle` is a Symfony bundle providing shared scaffolding for SumoCoders projects: page titles, +breadcrumbs, menus, forms, file/image uploads, audit logging, pagination, and a frontend design system. + +Install via the [application-skeleton](https://github.com/sumocoders/application-skeleton). + +Requirements: + +- PHP ^8.5, +- Symfony ^8.0, +- Doctrine ^3.3. + +--- + +## Subsystems + +| Doc | What it does | +|----------------------------------------------------|----------------------------------------------------------------------------------------| +| [crud.md](crud.md) | Standard CRUD pattern: controllers, DTO, form type, templates, translations | +| [breadcrumb.md](breadcrumb.md) | `#[Breadcrumb]` attribute, builds breadcrumb trails from controller annotations | +| [title.md](title.md) | `#[Title]` attribute, sets the page `` and `<h1>` | +| [audit-trail.md](audit-trail.md) | `#[AuditTrail]` attribute, logs entity creates/updates/deletes | +| [pagination.md](pagination.md) | `Paginator`, wraps a Doctrine QueryBuilder for paginated results | +| [menu.md](menu.md) | `MenuBuilder` + `ConfigureMenuEvent`, KnpMenu-based navigation | +| [forms.md](forms.md) | Custom form types (`ImageType`, `FileType`, `BelgiumPostCodeType`) and type extensions | +| [uploading-files.md](uploading-files.md) | `AbstractFile` + DBAL type, file upload value objects wired to Doctrine | +| [uploading-images.md](uploading-images.md) | `AbstractImage` + DBAL type, image upload value objects with fallback support | +| [encrypted.md](encrypted.md) | `EncryptedDBALType`, transparent field-level encryption via libsodium | +| [mails.md](mails.md) | Bundle email base template and async dispatch pattern | +| [using-date-pickers.md](using-date-pickers.md) | Date/time picker form type extensions | +| [button-locations.md](button-locations.md) | Toolbar and form submit button placement conventions | +| [language-switch.md](language-switch.md) | Multi-locale navigation switcher | +| [installation.md](installation.md) | Frontend asset installation | +| [frontend-development.md](frontend-development.md) | SCSS variables, dark mode, JS components | +| [ajax-client.md](ajax-client.md) | Axios-based AJAX client with CSRF and toast support | +| [asset-mapper.md](asset-mapper.md) | Adding CSS/JS packages via Symfony AssetMapper | +| [dark-mode.md](dark-mode.md) | Dark mode support and how to disable it | +| [stimulus.md](stimulus.md) | Stimulus controllers provided by the bundle | +| [no-results.md](no-results.md) | Standard empty-state / no-results UI component | + +--- + +## Architecture + +### Request lifecycle + +``` +HTTP request + └─ kernel.controller_arguments (priority -1) + ├─ BreadcrumbListener reads #[Breadcrumb] from class + method, populates BreadcrumbTrail + └─ TitleListener reads #[Title] from method, writes PageTitle. Falls back to BreadcrumbTrail if no #[Title] present +``` + +### Key injectable services + +| Class | Purpose | Inject as | +|----------------------------------------------------------|--------------------------------------------------|------------------------------------| +| `SumoCoders\FrameworkCoreBundle\Service\BreadcrumbTrail` | Current request breadcrumbs (iterable) | `BreadcrumbTrail $breadcrumbTrail` | +| `SumoCoders\FrameworkCoreBundle\Service\PageTitle` | Computed page title string | `PageTitle $pageTitle` | +| `SumoCoders\FrameworkCoreBundle\Service\Fallbacks` | Global config fallbacks (site title etc.) | `Fallbacks $fallbacks` | +| `SumoCoders\FrameworkCoreBundle\Menu\MenuBuilder` | KnpMenu factory; dispatches `ConfigureMenuEvent` | `MenuBuilder $menuBuilder` | +| `SumoCoders\FrameworkCoreBundle\Pagination\Paginator` | Paginates a `QueryBuilder` | constructor injection | +| `SumoCoders\FrameworkCoreBundle\Logger\AuditLogger` | Writes audit log entries | `AuditLogger $auditLogger` | + +### PHP attributes + +| Attribute | Target | What it does | +|--------------------|-----------------|----------------------------------------------------------------------| +| `#[Breadcrumb]` | method / class | Adds one crumb to the trail; repeatable; supports `parent:` chaining | +| `#[Title]` | method | Explicit page title with `{param}` interpolation | +| `#[AuditTrail]` | entity class | Enables Doctrine audit logging | +| `#[SensitiveData]` | entity property | Masks value in audit log as `*****` | + +### Service configuration + +All services are registered in `config/services.php` using PHP-format DI config. Autowiring and autoconfiguration are +enabled. `Configuration.php` is intentionally empty. No runtime bundle config is needed. + +--- + +## External packages (not documented here) + +These packages are commonly used alongside this bundle. Consult their own documentation: + +| Package | Docs | +|---------------------------------------|-----------------------------------------------------------------------------------------------------| +| `symfony/ux-autocomplete` | [Symfony UX Autocomplete](https://symfony.com/bundles/ux-autocomplete/current/index.html) | +| `doctrine/doctrine-fixtures-bundle` | [DoctrineFixturesBundle](https://symfony.com/bundles/DoctrineFixturesBundle/current/index.html) | +| `doctrine/doctrine-migrations-bundle` | [DoctrineMigrationsBundle](https://symfony.com/bundles/DoctrineMigrationsBundle/current/index.html) | +| `dreadnip/chrome-pdf-bundle` | [chrome-pdf-bundle on GitHub](https://github.com/sanderdlm/chrome-pdf-bundle) | +| Deployer | Deployment config is application-specific, see your project's `deploy.php` | diff --git a/docs/language-switch.md b/docs/language-switch.md new file mode 100644 index 00000000..2ec7335d --- /dev/null +++ b/docs/language-switch.md @@ -0,0 +1,59 @@ +# Language switch + +The bundle supports multi-locale navigation. The active locale is part of the route URL via the `{_locale}` parameter. A +dropdown in the navigation lets users switch language. + +## Prerequisites + +Routing must be configured with `{_locale}` as a route parameter or prefix in `config/routes.yaml`. + +## Adding a new locale + +Extend the `locales` parameter in `config/services.yaml`: + +```yaml +parameters: + locales: [ 'nl', 'fr', 'en' ] +``` + +The `locales` parameter is passed to Twig and used in the language switcher dropdown. + +## Language switch snippet + +Add the following to `templates/navigation.html.twig`, between the logo and the user menu: + +```twig +<div class="navbar-header d-md-none d-flex align-items-center"> + <div class="dropdown btn-group d-flex flex-column me-3"> + <a class="dropdown-toggle d-flex align-items-center" + href="#" + id="dropdown-language" + role="button" + data-bs-toggle="dropdown" + aria-haspopup="true" + aria-expanded="false"> + {{ app.request.locale|upper }} + <span class="bi bi-chevron-down"></span> + </a> + <ul class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdown-language"> + {% for locale in locales %} + <li> + <a class="dropdown-item {{ locale == app.request.locale ? 'active' : '' }}" + href="{{ path(app.request.attributes.get('_route'), app.request.attributes.get('_route_params')|merge({'_locale': locale})) }}"> + {{ locale|upper }} + </a> + </li> + {% endfor %} + </ul> + </div> +</div> +``` + +## Troubleshooting + +- **Locale not changing on click**: verify your routes include `{_locale}` as a parameter or prefix; without it, + `_locale` in `_route_params` has no effect +- **`locales` variable undefined in Twig**: ensure the `locales` parameter is defined in `config/services.yaml` under + `parameters:` +- **Wrong locale active after switch**: check that the Symfony locale listener is active ( + `framework.translator.enabled_locales` in `config/packages/translation.yaml`) diff --git a/docs/development/mails.md b/docs/mails.md similarity index 75% rename from docs/development/mails.md rename to docs/mails.md index 54239ca0..69ec0cf3 100644 --- a/docs/development/mails.md +++ b/docs/mails.md @@ -13,6 +13,7 @@ To send the mail, you can use the default Symfony package: `symfony/mailer`. See ## Async Emails will be sent via the async messenger transport, meaning: + 1. The context must be serializable 2. Run `console messenger:consume async` (default already in .crontab) @@ -32,6 +33,18 @@ template.html.twig {% endblock %} ``` +## Template styling + +Mail templates use [Foundation for Emails](https://get.foundation/emails/docs/global.html) for responsive table-based +layouts. Consult the Foundation docs for available components and helpers. + +### Logo + +Use a logo with fixed width (182px) set directly on the `img` tag. Some mail clients cannot handle CSS-based image +sizing. Use `.gif` extension for the broadest compatibility. + +When you change the logo width, update the `width` attribute on the `img` tag to match. + ```php <?php diff --git a/docs/menu.md b/docs/menu.md new file mode 100644 index 00000000..69cb479a --- /dev/null +++ b/docs/menu.md @@ -0,0 +1,136 @@ +# Menu + +The bundle builds navigation using [KnpMenu](https://symfony.com/bundles/KnpMenuBundle/current/index.html). +`MenuBuilder` dispatches a `ConfigureMenuEvent`, consuming apps listen to this event to add items. + +## Prerequisites + +`knplabs/knp-menu-bundle` must be installed (included in the application skeleton). + +## Usage + +Create an event listener in `src/EventListener/`: + +```php +<?php + +namespace App\EventListener; + +use SumoCoders\FrameworkCoreBundle\Event\ConfigureMenuEvent; +use SumoCoders\FrameworkCoreBundle\EventListener\DefaultMenuListener; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +class MenuListener extends DefaultMenuListener implements EventSubscriberInterface +{ + public function onConfigureMenu(ConfigureMenuEvent $event): void + { + $factory = $event->getFactory(); + $menu = $event->getMenu(); + + if ($this->getSecurity()->isGranted('ROLE_ADMIN')) { + $menu->addChild( + $factory->createItem( + $this->getTranslator()->trans('Users'), + [ + 'route' => 'user_admin_overview', + 'labelAttributes' => [ + 'icon' => 'bi bi-person-fill', + ], + 'extras' => [ + 'routes' => [ + 'user_admin_add', + 'user_admin_edit', + ], + ], + ], + ) + ); + } + } + + public static function getSubscribedEvents(): array + { + return [ConfigureMenuEvent::EVENT_NAME => 'onConfigureMenu']; + } +} +``` + +Register the listener in `config/services.yaml`: + +```yaml +services: + App\EventListener\MenuListener: + tags: + - { name: kernel.event_listener, event: framework_core.configure_menu, method: onConfigureMenu } +``` + +## DefaultMenuListener helpers + +Extending `DefaultMenuListener` gives you three autowired services: + +| Method | Returns | Purpose | +|----------------------------|-----------------------|----------------------------| +| `$this->getTranslator()` | `TranslatorInterface` | Translate menu item labels | +| `$this->getSecurity()` | `Security` | Check roles/permissions | +| `$this->getRequestStack()` | `RequestStack` | Access current request | + +## Active state for child routes + +`enableChildRoutes($prefix)` marks a menu item as active when the current route starts with `$prefix`: + +```php +$usersItem = $factory->createItem('Users', ['route' => 'user_admin_overview']); +$this->enableChildRoutes($usersItem, 'user_admin_'); +$menu->addChild($usersItem); +``` + +All routes starting with `user_admin_` (e.g. `user_admin_add`, `user_admin_edit`) will mark the item as active. + +Alternatively, list specific routes in `extras.routes`: + +```php +'extras' => [ + 'routes' => ['user_admin_add', 'user_admin_edit'], +], +``` + +## Icons + +The bundle supports Bootstrap Icons (`bi bi-*`) and Font Awesome (`fa-* fa-*`) in `labelAttributes.icon`: + +```php +'labelAttributes' => ['icon' => 'bi bi-house-fill'], // Bootstrap Icons +'labelAttributes' => ['icon' => 'fa-solid fa-house'], // Font Awesome +``` + +## Nested items (dropdown) + +Create a parent item with `uri => '#'` and add children to it before adding to the root menu: + +```php +$paymentsItem = $factory->createItem( + $this->getTranslator()->trans('Payments'), + [ + 'uri' => '#', + 'labelAttributes' => ['icon' => 'fa-regular fa-credit-card'], + ] +); + +$paymentsItem->addChild( + $factory->createItem( + $this->getTranslator()->trans('Overview'), + [ + 'route' => 'payments_overview', + 'labelAttributes' => ['icon' => 'fa-solid fa-money-bill'], + ] + ) +); + +$menu->addChild($paymentsItem); +``` + +## Troubleshooting + +- **Menu item not highlighted**: add the route to `extras.routes` or use `enableChildRoutes` with the correct prefix +- **Item visible to wrong roles**: `isGranted` checks happen at render time; wrap the `addChild` call in a role check +- **Menu not rendering**: verify the listener is registered and tagged with `framework_core.configure_menu` diff --git a/docs/no-results.md b/docs/no-results.md new file mode 100644 index 00000000..eca515d8 --- /dev/null +++ b/docs/no-results.md @@ -0,0 +1,34 @@ +# No-results state + +Use this component when a list, datagrid, or page has no data to display, including after filtering produces zero +results. + +## Usage + +```twig +<div class="data-no-results"> + <img src="{{ asset('images/no-results.svg') }}" alt=""> + {{ 'your.translation.key'|trans }} +</div> +``` + +Replace `'your.translation.key'` with a translation key appropriate to the context (e.g. `'users.empty'`, +`'orders.no_results'`). + +## With a filter hint + +When the empty state is caused by an active filter, add a reset link so the user can clear it: + +```twig +{% if filtersActive %} + <div class="data-no-results"> + <img src="{{ asset('images/no-results.svg') }}" alt=""> + {{ 'your.translation.key'|trans }} + <a href="{{ path(app.request.attributes.get('_route')) }}">{{ 'general.reset_filters'|trans }}</a> + </div> +{% endif %} +``` + +## Accessibility + +The `<img>` carries an empty `alt=""` because it is decorative. The text content must be meaningful on its own. diff --git a/docs/pagination.md b/docs/pagination.md new file mode 100644 index 00000000..4e83c136 --- /dev/null +++ b/docs/pagination.md @@ -0,0 +1,181 @@ +# Pagination + +`Paginator` wraps a Doctrine `QueryBuilder` and handles page math, result slicing, and iteration. The default page size +is 30. + +## Usage + +### Repository + +Return a `Paginator` from the repository method. Do not call `paginate()` here, the controller does that. + +```php +<?php + +namespace App\Repository; + +use App\Entity\Item; +use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\Persistence\ManagerRegistry; +use SumoCoders\FrameworkCoreBundle\Pagination\Paginator; + +class ItemRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Item::class); + } + + public function getPaginated(): Paginator + { + $queryBuilder = $this->createQueryBuilder('i') + ->orderBy('i.name', 'ASC'); + + return new Paginator($queryBuilder); + } +} +``` + +### Controller + +Call `paginate()` with the current page number from the query string: + +```php +<?php + +namespace App\Controller\Item; + +use App\Repository\ItemRepository; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; + +#[Route('/items', name: 'item_index')] +final class Index extends AbstractController +{ + public function __invoke(Request $request, ItemRepository $itemRepository): Response + { + $items = $itemRepository->getPaginated() + ->paginate($request->query->getInt('page', 1)); + + return $this->render('item/index.html.twig', [ + 'items' => $items, + ]); + } +} +``` + +### Template + +The `Paginator` is iterable and countable. Use the `pagination()` Twig function to render the pager widget: + +```twig +{% if items|length > 0 %} + {% for item in items %} + <div>{{ item.name }}</div> + {% endfor %} +{% else %} + {% include 'partials/no-results.html.twig' %} +{% endif %} + +{% if items.hasToPaginate %} + <div class="d-flex justify-content-center"> + {{ pagination(items) }} + </div> +{% endif %} +``` + +## `Paginator` API reference + +| Method | Return type | Description | +|------------------------------|---------------|----------------------------------------------------------------------------| +| `paginate(int $page = 1)` | `self` | Executes the query for the given page; returns `$this` | +| `getCurrentPage()` | `int` | Current page number | +| `getLastPage()` | `int` | Last page number (= total pages) | +| `getPageSize()` | `int` | Items per page (default 30) | +| `hasPreviousPage()` | `bool` | Whether a previous page exists | +| `getPreviousPage()` | `int` | Previous page number (minimum 1) | +| `hasNextPage()` | `bool` | Whether a next page exists | +| `getNextPage()` | `int` | Next page number (maximum last page) | +| `hasToPaginate()` | `bool` | Whether there is more than one page | +| `getNumResults()` | `int` | Total number of results across all pages | +| `getResults()` | `Traversable` | Results for the current page | +| `calculateStartAndEndPage()` | `void` | Populates `startPage`/`endPage` (±3 around current page) for pager UI | +| `getStartPage()` | `int` | First page number in the pager window (after `calculateStartAndEndPage()`) | +| `getEndPage()` | `int` | Last page number in the pager window (after `calculateStartAndEndPage()`) | + +Custom page size: + +```php +return new Paginator($queryBuilder, pageSize: 10); +``` + +## Sorting + +Add an `orderBy` to the query builder and pass the sort direction from the request: + +```php +public function getPaginated(string $sortField = 'name', string $sortDirection = 'ASC'): Paginator +{ + $allowedFields = ['name', 'email', 'createdAt']; + if (!in_array($sortField, $allowedFields, true)) { + $sortField = 'name'; + } + + $queryBuilder = $this->createQueryBuilder('u') + ->orderBy('u.' . $sortField, $sortDirection === 'DESC' ? 'DESC' : 'ASC'); + + return new Paginator($queryBuilder); +} +``` + +Controller: + +```php +$users = $userRepository->getPaginated( + $request->query->get('sort', 'name'), + $request->query->get('direction', 'ASC'), +)->paginate($request->query->getInt('page', 1)); +``` + +## Filters with session persistence + +Without session storage, the filter resets when the user navigates to page 2. Store filter data in the session to +persist it across page requests. + +```php +<?php + +use App\Form\UserFilterType; +use App\Form\UserFilterData; + +$filterData = $request->getSession()->has('user_filter') + ? unserialize($request->getSession()->get('user_filter')) + : new UserFilterData(); + +$form = $this->createForm(UserFilterType::class, $filterData); +$form->handleRequest($request); + +if ($form->isSubmitted() && $form->isValid()) { + $filterData = $form->getData(); + $request->getSession()->set('user_filter', serialize($filterData)); +} + +$users = $userRepository->getFiltered($filterData) + ->paginate($request->query->getInt('page', 1)); +``` + +To reset the filter, remove the session key: + +```php +$request->getSession()->remove('user_filter'); +``` + +## Troubleshooting + +- **Total count is wrong with JOINs**: the paginator sets `HINT_DISTINCT => false` when no JOINs are present. With + JOINs, ensure your query does not produce duplicate root entities +- **`paginate()` not called**: always call `paginate()` before passing the paginator to the template; calling only the + constructor does not execute the query +- **Page parameter missing**: use `$request->query->getInt('page', 1)` so an absent `?page=` defaults to page 1 diff --git a/docs/frontend/stimulus.md b/docs/stimulus.md similarity index 90% rename from docs/frontend/stimulus.md rename to docs/stimulus.md index 39754456..13e4d749 100644 --- a/docs/frontend/stimulus.md +++ b/docs/stimulus.md @@ -1,10 +1,25 @@ -# Stimulus +# Stimulus controllers -For our javascript needs we use Symfony UX with Stimulus. See the Symfony site for -information https://symfony.com/bundles/StimulusBundle/current/index.html or the stimulus documentation for more -information: https://stimulus.hotwired.dev/handbook/introduction +The bundle provides pre-built Stimulus controllers for common UI patterns. These are registered automatically when the +frontend assets are installed. -We have a few default components that can be used in your project: +## Prerequisites + +Assets installed: see [installation.md](installation.md). Import the bundle's controllers in your +`assets/controllers.json` or `assets/bootstrap.js`. + +## Lifecycle hooks + +Every Stimulus controller supports: + +| Method | When it runs | +|----------------|-------------------------------------------------------| +| `connect()` | When the controller's element is connected to the DOM | +| `disconnect()` | When the element is removed from the DOM | + +Custom controllers should call `super.connect()` if they extend a bundle controller. + +## Bundle controllers ## Clipboard @@ -202,7 +217,6 @@ Set the `data-controller='busy-submit'` on the form tag or in your form type: ]); ``` - ## Confirm modal This controller allows you to show a confirmation modal before submitting a form or clicking a link. diff --git a/docs/development/title.md b/docs/title.md similarity index 56% rename from docs/development/title.md rename to docs/title.md index 786a55b1..f1cf9917 100644 --- a/docs/development/title.md +++ b/docs/title.md @@ -1,5 +1,18 @@ # Page title +Sets the page `<title>` and `<h1>` from `#[Title]` attributes or, when absent, from the breadcrumb trail. Available in +all Twig templates as the `pageTitle` variable. + +## `#[Title]` options + +| Parameter | Type | Default | Description | +|-----------|---------------|----------|-----------------------------------------------------------------------------------------------------------------------| +| `title` | `string` | required | Page title. Supports `{param}` (scalar) and `{object.property}` (object) interpolation. Passed through the translator | +| `parent` | `array\|null` | `null` | Prepends the title of another route. Keys: `name` (required), `parameters` (optional array) | +| `extend` | `bool` | `true` | When `true`, appends ` - <site_title>`. When `false`, uses the string verbatim | + +The attribute targets **methods only** and is **not repeatable**. + ## Resolution order The `PageTitle` service resolves the title in this order: @@ -14,8 +27,8 @@ Set `fallback.site_title` in your `services.yaml`: ```yaml parameters: - fallbacks: - site_title: 'My Application' + fallbacks: + site_title: 'My Application' ``` ## The `#[Title]` attribute @@ -65,7 +78,8 @@ public function __invoke(): Response Output: `Detail - Overview - My Application` -The parent chain is resolved recursively: if the parent route also has a `#[Title]` with its own parent, that is included too. +The parent chain is resolved recursively: if the parent route also has a `#[Title]` with its own parent, that is +included too. ### Dynamic titles @@ -91,7 +105,8 @@ public function __invoke( } ``` -Dynamic parameters are resolved from the named controller arguments. If a placeholder is not found, an exception is thrown. +Dynamic parameters are resolved from the named controller arguments. If a placeholder is not found, an exception is +thrown. ### Disable automatic appending @@ -129,4 +144,15 @@ In Twig, `PageTitle` is available as a string (via `__toString`): ```twig <title>{{ pageTitle }} +

{{ pageTitle }}

``` + +## Troubleshooting + +- **`{param}` not resolving**: the placeholder must match the exact name of a controller argument. For objects, use + `{object.property}` not `{object}` +- **Title missing site name**: verify `fallbacks.site_title` is set in `parameters` in `config/services.yaml` +- **Parent chain not working**: each route in the chain must exist and have `#[Title]` or `#[Breadcrumb]` attributes; + the chain resolves by dispatching a subrequest to fetch the parent's title +- **`extend: false` still appends site title**: check that `extend:` is passed as a named argument: + `#[Title('My Title', extend: false)]` diff --git a/docs/uploading-files.md b/docs/uploading-files.md new file mode 100644 index 00000000..d586bb6b --- /dev/null +++ b/docs/uploading-files.md @@ -0,0 +1,169 @@ +# Uploading files + +The bundle provides `AbstractFile`, a value object that handles file storage, naming, and lifecycle hooks for Doctrine +entities. Used with `FileType` for forms and a custom DBAL type for database persistence. + +## Prerequisites + +- A writable `public/files/` directory in your project +- The entity must have `#[ORM\HasLifecycleCallbacks]` + +## Step 1: Create the value object + +Create a class that extends `AbstractFile` and implements `getUploadDir()`. The upload directory is relative to +`public/files/`. + +```php +`. + +## Step 2: Create the DBAL type + +```php +document; + } + + public function setDocument(?UserDocument $document): void + { + $this->document = $document; + } + + #[ORM\PrePersist] + #[ORM\PreUpdate] + public function prepareDocument(): void + { + $this->document?->prepareToUpload(); + } + + #[ORM\PostPersist] + #[ORM\PostUpdate] + public function uploadDocument(): void + { + $this->document?->upload(); + } + + #[ORM\PostRemove] + public function removeDocument(): void + { + $this->document?->remove(); + } +} +``` + +## Step 4: Add to form + +```php +add('document', FileType::class, [ + 'file_class' => UserDocument::class, + 'label' => 'forms.labels.document', + 'help' => 'forms.help.document', + 'accept' => 'application/pdf', + 'show_preview' => true, + 'preview_label' => 'forms.labels.viewCurrentFile', + 'show_remove_file' => true, + 'remove_file_label' => 'forms.labels.removeFile', + 'required_file_error' => 'forms.not_blank', +]); +``` + +See [forms.md](forms.md) for the full `FileType` options reference. + +## Template + +```twig +{% if user.document %} + Download document +{% endif %} +``` + +`AbstractFile` implements `__toString()` returning the web path (`/files/user/documents/`), or an empty string +if no file exists. + +## `AbstractFile` API + +| Method | Description | +|-------------------------|--------------------------------------------------| +| `getFileName()` | Raw stored filename | +| `getWebPath()` | Public URL path, or empty string if file missing | +| `getAbsolutePath()` | Absolute filesystem path | +| `hasFile()` | Whether a new `UploadedFile` is pending | +| `markForDeletion()` | Schedules the file for removal on next flush | +| `setNamePrefix(string)` | Prepends a slug to the generated filename | + +## Troubleshooting + +- **File not uploaded after form submit**: verify the three lifecycle methods (`prepareToUpload`, `upload`, `remove`) + are present on the entity with the correct `#[ORM\*]` attributes +- **`public/files/` directory missing**: create it and ensure it is web-accessible; check your web server configuration +- **Old file not deleted on replace**: the `upload()` method deletes the old file; this only works if + `prepareToUpload()` was called first in `PreUpdate` +- **Form always shows file as required**: `FileType` uses `required` based on whether the value object has an existing + file; pass `'required' => false` to disable the constraint diff --git a/docs/uploading-images.md b/docs/uploading-images.md new file mode 100644 index 00000000..b9d2cf33 --- /dev/null +++ b/docs/uploading-images.md @@ -0,0 +1,174 @@ +# Uploading images + +The bundle provides `AbstractImage`, extends `AbstractFile` with image-specific features: fallback images and web path +resolution. Used with `ImageType` for forms and a custom DBAL type for database persistence. + +## Prerequisites + +- A writable `public/files/` directory in your project +- The entity must have `#[ORM\HasLifecycleCallbacks]` + +## Step 1: Create the value object + +Create a class that extends `AbstractImage` and implements `getUploadDir()`. Optionally override `FALLBACK_IMAGE` to +return a web path for when no image is set. + +```php +avatar; + } + + public function setAvatar(?UserAvatar $avatar): void + { + $this->avatar = $avatar; + } + + #[ORM\PrePersist] + #[ORM\PreUpdate] + public function prepareAvatar(): void + { + $this->avatar?->prepareToUpload(); + } + + #[ORM\PostPersist] + #[ORM\PostUpdate] + public function uploadAvatar(): void + { + $this->avatar?->upload(); + } + + #[ORM\PostRemove] + public function removeAvatar(): void + { + $this->avatar?->remove(); + } +} +``` + +## Step 4: Add to form + +```php +add('avatar', ImageType::class, [ + 'image_class' => UserAvatar::class, + 'label' => 'forms.labels.avatar', + 'help' => 'forms.help.avatar', + 'accept' => 'image/jpeg,image/png,image/webp', + 'show_preview' => true, + 'show_remove_image' => true, + 'remove_image_label' => 'forms.labels.removeImage', + 'required_image_error' => 'forms.not_blank', +]); +``` + +See [forms.md](forms.md) for the full `ImageType` options reference. + +## Template + +```twig +{{ user.name }} +``` + +`getWebPath()` returns the uploaded image URL or `FALLBACK_IMAGE` if no image has been uploaded. Returns an empty string +if no fallback is defined and no file exists. + +## `AbstractImage` API + +Inherits all `AbstractFile` methods plus: + +| Method | Description | +|----------------------|-------------------------------------------------------| +| `getWebPath()` | Public image URL, or `FALLBACK_IMAGE` if file missing | +| `getFallbackImage()` | Returns the value of `FALLBACK_IMAGE` constant | + +## Notes + +- The bundle does not resize or optimize images. Handle resizing in the entity lifecycle callbacks or a post-upload + event if needed. +- EXIF data is not stripped. For user-uploaded images consider stripping EXIF in the lifecycle callback before calling + `upload()`. + +## Troubleshooting + +- **Image not uploaded after form submit**: verify the three lifecycle methods (`prepareToUpload`, `upload`, `remove`) + are present on the entity +- **Fallback image not showing**: `FALLBACK_IMAGE` must be an absolute public path (e.g. `/images/no-avatar.png`), not + a relative path +- **Old image not deleted on replace**: ensure `prepareToUpload()` is called in `PreUpdate`; it stores the old filename + for deletion during `upload()` +- **Preview not showing in form**: the `ImageType` calls `getWebPath()` on the current value; check the file exists at + the returned path diff --git a/docs/using-date-pickers.md b/docs/using-date-pickers.md new file mode 100644 index 00000000..43da63d6 --- /dev/null +++ b/docs/using-date-pickers.md @@ -0,0 +1,71 @@ +# Date and time pickers + +The bundle uses [Flatpickr](https://flatpickr.js.org/) for date and time inputs. Flatpickr activates automatically on +any field using these Symfony form types — no extra configuration required: + +- `DateType` +- `TimeType` +- `DateTimeType` +- `BirthdayType` + +## Prerequisites + +The `widget` option must be `single_text` (the bundle default). Do not set `html5: true`. + +## Usage + +```php +add('date', DateType::class, [ + 'data' => new \DateTime(), +]); +``` + +## Options + +The bundle adds two convenience options to the date/time types: + +| Option | Type | Default | Description | +|----------------|----------------|----------------|------------------------------------------------| +| `minimum_date` | `string\|null` | `null` | Earliest selectable date, formatted as `d/m/Y` | +| `maximum_date` | `string\|null` | `null` | Latest selectable date, formatted as `d/m/Y` | +| `format` | `string` | `'dd/MM/yyyy'` | Display format (Flatpickr format string) | + +```php +$builder->add('date', DateType::class, [ + 'data' => new \DateTime(), + 'minimum_date' => (new \DateTimeImmutable('last week'))->format('d/m/Y'), + 'maximum_date' => (new \DateTimeImmutable('next week'))->format('d/m/Y'), +]); +``` + +## Passing additional Flatpickr options + +Pass any [Flatpickr option](https://flatpickr.js.org/options/) as a `data-date-*` attribute, converting camelCase to +kebab-case: + +- `minDate` → `data-date-min-date` +- `showMonths` → `data-date-show-months` + +```php +$builder->add('date', DateType::class, [ + 'data' => new \DateTime(), + 'minimum_date' => (new \DateTimeImmutable('last week'))->format('d/m/Y'), + 'maximum_date' => (new \DateTimeImmutable('next week'))->format('d/m/Y'), + 'attr' => [ + 'data-date-min-date' => '01/01/1993', + 'data-date-show-months' => 2, + ], +]); +``` + +## Troubleshooting + +- **Picker does not open**: verify `widget` is not set to `choice` and `html5` is not `true` +- **Date format mismatch on submit**: the `format` option controls the display format; the form submits the underlying + ISO value regardless of display format +- **`minimum_date`/`maximum_date` ignored**: these options must be a string in `d/m/Y` format matching the `format` + option