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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -1146,6 +1146,31 @@ The documentation covers:
- Doctrine type enrichment;
- current limitations.

### Boolean cell display modes

Boolean cells support configurable display modes.

Supported modes:

```text
badge
icon
switch
text
```

`badge` remains the default.

The display mode can be provided through render options:

```twig
{{ zhortein_datatable('users', {
booleanDisplayMode: 'icon'
}) }}
```

The implementation stays display-only. Switch mode renders a disabled Bootstrap switch and does not mutate data.

### Bootstrap template cleanup

Bootstrap templates have been reviewed for readability and consistency.
Expand Down
57 changes: 57 additions & 0 deletions docs/cell-templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,63 @@ Custom template existence is not validated when the datatable definition is buil

Twig errors remain explicit at render time.

## Boolean display modes

Boolean cells support several display modes:

```text
badge
icon
switch
text
```

### Badge mode

Badge mode is the default.

It renders translated `Yes` / `No` labels as Bootstrap badges.

```twig
{{ zhortein_datatable('users', {
booleanDisplayMode: 'badge'
}) }}
```

### Icon mode

Icon mode renders decorative check/cross characters with visually hidden translated labels.

```twig
{{ zhortein_datatable('users', {
booleanDisplayMode: 'icon'
}) }}
```

No icon library is required.

### Switch mode

Switch mode renders a disabled Bootstrap switch.

```twig
{{ zhortein_datatable('users', {
booleanDisplayMode: 'switch'
}) }}
```

The switch is display-only and does not update data.

### Text mode

Text mode renders the translated label only.

```twig
{{ zhortein_datatable('users', {
booleanDisplayMode: 'text'
}) }}
```

## Related documentation

- [`templates.md`](templates.md)
Expand Down
22 changes: 22 additions & 0 deletions src/Enum/BooleanDisplayMode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace Zhortein\DatatableBundle\Enum;

enum BooleanDisplayMode: string
{
case Badge = 'badge';
case Icon = 'icon';
case Switch = 'switch';
case Text = 'text';

public static function fromNullableString(?string $mode): self
{
if (null === $mode || '' === trim($mode)) {
return self::Badge;
}

return self::tryFrom(strtolower(trim($mode))) ?? self::Badge;
}
}
12 changes: 12 additions & 0 deletions src/Renderer/DatatableRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use Zhortein\DatatableBundle\Definition\ColumnDefinition;
use Zhortein\DatatableBundle\Definition\DatatableDefinition;
use Zhortein\DatatableBundle\Enum\ActionDisplayMode;
use Zhortein\DatatableBundle\Enum\BooleanDisplayMode;
use Zhortein\DatatableBundle\Enum\CellType;
use Zhortein\DatatableBundle\Result\DatatableResult;

Expand Down Expand Up @@ -221,6 +222,7 @@ private function normalizeRows(DatatableDefinition $definition, DatatableResult
'value' => $this->readColumnValue($row, $column),
'template' => $this->resolveCellTemplate($column),
'className' => $this->resolveCellClassName($column),
'booleanDisplayMode' => $this->resolveBooleanDisplayMode($options)->value,
];
}

Expand Down Expand Up @@ -399,4 +401,14 @@ private function createHtmlId(DatatableDefinition $definition): string

return 'zhortein-datatable-'.strtolower(trim($name, '-'));
}

/**
* @param array<string, mixed> $options
*/
private function resolveBooleanDisplayMode(array $options): BooleanDisplayMode
{
$mode = $options['booleanDisplayMode'] ?? null;

return BooleanDisplayMode::fromNullableString(is_string($mode) ? $mode : null);
}
}
3 changes: 2 additions & 1 deletion templates/bootstrap/_cell.html.twig
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<td{% if class_name is not null %} class="{{ class_name }}"{% endif %}>
{% include template with {
column: column,
value: value
value: value,
boolean_display_mode: boolean_display_mode
} only %}
</td>
3 changes: 2 additions & 1 deletion templates/bootstrap/_row.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
column: cell.column,
value: cell.value,
template: cell.template,
class_name: cell.className
class_name: cell.className,
boolean_display_mode: cell.booleanDisplayMode
} only %}
{% endfor %}

Expand Down
33 changes: 30 additions & 3 deletions templates/bootstrap/cell/boolean.html.twig
Original file line number Diff line number Diff line change
@@ -1,5 +1,32 @@
{% if value %}
<span class="badge text-bg-success">{{ 'zhortein_datatable.boolean.yes'|trans({}, 'zhortein_datatable') }}</span>
{% set display_mode = boolean_display_mode ?? 'badge' %}
{% set yes_label = 'zhortein_datatable.boolean.yes'|trans({}, 'zhortein_datatable') %}
{% set no_label = 'zhortein_datatable.boolean.no'|trans({}, 'zhortein_datatable') %}

{% if display_mode == 'icon' %}
{% if value %}
<span class="text-success" aria-hidden="true">✓</span>
<span class="visually-hidden">{{ yes_label }}</span>
{% else %}
<span class="text-danger" aria-hidden="true">×</span>
<span class="visually-hidden">{{ no_label }}</span>
{% endif %}
{% elseif display_mode == 'switch' %}
<span class="form-check form-switch d-inline-flex m-0">
<input
class="form-check-input"
type="checkbox"
role="switch"
disabled
aria-label="{{ value ? yes_label : no_label }}"
{% if value %}checked{% endif %}
>
</span>
{% elseif display_mode == 'text' %}
{{ value ? yes_label : no_label }}
{% else %}
<span class="badge text-bg-secondary">{{ 'zhortein_datatable.boolean.no'|trans({}, 'zhortein_datatable') }}</span>
{% if value %}
<span class="badge text-bg-success">{{ yes_label }}</span>
{% else %}
<span class="badge text-bg-secondary">{{ no_label }}</span>
{% endif %}
{% endif %}
27 changes: 27 additions & 0 deletions tests/Unit/Enum/BooleanDisplayModeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace Zhortein\DatatableBundle\Tests\Unit\Enum;

use PHPUnit\Framework\TestCase;
use Zhortein\DatatableBundle\Enum\BooleanDisplayMode;

final class BooleanDisplayModeTest extends TestCase
{
public function test_it_creates_display_mode_from_valid_string(): void
{
self::assertSame(BooleanDisplayMode::Badge, BooleanDisplayMode::fromNullableString('badge'));
self::assertSame(BooleanDisplayMode::Icon, BooleanDisplayMode::fromNullableString('icon'));
self::assertSame(BooleanDisplayMode::Switch, BooleanDisplayMode::fromNullableString('switch'));
self::assertSame(BooleanDisplayMode::Text, BooleanDisplayMode::fromNullableString('text'));
self::assertSame(BooleanDisplayMode::Icon, BooleanDisplayMode::fromNullableString(' ICON '));
}

public function test_it_falls_back_to_badge_for_null_empty_or_unknown_value(): void
{
self::assertSame(BooleanDisplayMode::Badge, BooleanDisplayMode::fromNullableString(null));
self::assertSame(BooleanDisplayMode::Badge, BooleanDisplayMode::fromNullableString(''));
self::assertSame(BooleanDisplayMode::Badge, BooleanDisplayMode::fromNullableString('unknown'));
}
}
128 changes: 128 additions & 0 deletions tests/Unit/Renderer/DatatableRendererBooleanDisplayModeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<?php

declare(strict_types=1);

namespace Zhortein\DatatableBundle\Tests\Unit\Renderer;

use PHPUnit\Framework\TestCase;
use Twig\Environment;
use Twig\Loader\FilesystemLoader;
use Zhortein\DatatableBundle\Definition\DatatableDefinition;
use Zhortein\DatatableBundle\Enum\BooleanDisplayMode;
use Zhortein\DatatableBundle\Renderer\DatatableRenderer;
use Zhortein\DatatableBundle\Result\DatatableResult;

final class DatatableRendererBooleanDisplayModeTest extends TestCase
{
use TranslatableRendererTestTrait;

public function test_it_renders_boolean_as_badge_by_default(): void
{
$html = $this->renderBoolean(true);

self::assertStringContainsString('badge text-bg-success', $html);
self::assertStringContainsString('Yes', $html);
}

public function test_it_renders_boolean_false_as_badge_by_default(): void
{
$html = $this->renderBoolean(false);

self::assertStringContainsString('badge text-bg-secondary', $html);
self::assertStringContainsString('No', $html);
}

public function test_it_renders_boolean_as_icon(): void
{
$html = $this->renderBoolean(true, BooleanDisplayMode::Icon);

self::assertStringContainsString('text-success', $html);
self::assertStringContainsString('✓', $html);
self::assertStringContainsString('visually-hidden', $html);
self::assertStringContainsString('Yes', $html);
self::assertStringNotContainsString('badge text-bg-success', $html);
}

public function test_it_renders_boolean_false_as_icon(): void
{
$html = $this->renderBoolean(false, BooleanDisplayMode::Icon);

self::assertStringContainsString('text-danger', $html);
self::assertStringContainsString('×', $html);
self::assertStringContainsString('No', $html);
}

public function test_it_renders_boolean_as_switch(): void
{
$html = $this->renderBoolean(true, BooleanDisplayMode::Switch);

self::assertStringContainsString('form-check form-switch', $html);
self::assertStringContainsString('role="switch"', $html);
self::assertStringContainsString('disabled', $html);
self::assertStringContainsString('checked', $html);
self::assertStringContainsString('aria-label="Yes"', $html);
}

public function test_it_renders_boolean_false_as_switch(): void
{
$html = $this->renderBoolean(false, BooleanDisplayMode::Switch);

self::assertStringContainsString('form-check form-switch', $html);
self::assertStringContainsString('aria-label="No"', $html);
self::assertStringNotContainsString('checked', $html);
}

public function test_it_renders_boolean_as_text(): void
{
$html = $this->renderBoolean(true, BooleanDisplayMode::Text);

self::assertStringContainsString('Yes', $html);
self::assertStringNotContainsString('badge', $html);
self::assertStringNotContainsString('form-check', $html);
}

private function renderBoolean(bool $value, ?BooleanDisplayMode $mode = null): string
{
$definition = new DatatableDefinition('users');

$definition->addColumn(
name: 'enabled',
label: 'Enabled',
type: 'boolean',
);

$options = [];

if (null !== $mode) {
$options['booleanDisplayMode'] = $mode->value;
}

return new DatatableRenderer($this->createTwigEnvironment())->renderBody(
$definition,
new DatatableResult(
rows: [
[
'enabled' => $value,
],
],
totalItems: 1,
),
$options,
);
}

private function createTwigEnvironment(): Environment
{
$loader = new FilesystemLoader();
$loader->addPath(__DIR__.'/../../../templates', 'ZhorteinDatatable');

$twig = new Environment($loader, [
'strict_variables' => true,
'autoescape' => 'html',
]);

$this->addTranslationExtension($twig);

return $twig;
}
}