Inline edit #181
Replies: 2 comments
-
Well, after doing some research I ended up with following approach. Of course, can be improved a lot, but I hope that we could discuss it together to find a better reusable solution for everybody. First of all, I've created a custom column type: <?php
namespace App\DataTable\Column\Type;
use Kreyu\Bundle\DataTableBundle\Column\ColumnInterface;
use Kreyu\Bundle\DataTableBundle\Column\ColumnValueView;
use Kreyu\Bundle\DataTableBundle\Column\Type\AbstractColumnType;
use Kreyu\Bundle\DataTableBundle\Column\Type\ColumnType;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class InlineEditableCellColumnType extends AbstractColumnType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver
->setRequired('url')
->setRequired('class')
->setRequired('property')
->setAllowedTypes('url', 'callable')
->setAllowedTypes('class', 'string')
->setAllowedTypes('property', 'string')
;
}
public function buildValueView(ColumnValueView $view, ColumnInterface $column, array $options): void
{
$view->vars['url'] = (string) $options['url']($view->parent->data); // this resolves (execute it) the closure with iterated entity parameter stored inside $view->parent->data
$view->vars['class'] = (string) $options['class'];
$view->vars['property'] = (string) $options['property'];
}
public function getParent(): ?string
{
return ColumnType::class;
}
}
Then there is an example about how to use it with a Supplier (example) entity <?php
namespace App\DataTable\Main\Type;
//... common uses
final class SupplierDataTableType extends AbstractDataTableType
{
public function buildDataTable(DataTableBuilderInterface $builder, array $options): void
{
$builder
->addColumn(
'phone',
InlineEditableCellColumnType::class,
[
'url' => function (Supplier $supplier) {
return $this->router->generate('app_internal_api_edit_inline_field', ['id' => $supplier->getId()]);
},
'class' => Supplier::class,
'property' => 'phone',
]
) Keep in mind that there is an {% extends '@KreyuDataTable/themes/bootstrap_5.html.twig' %}
{% block column_inline_editable_cell_value %}
<div {{ stimulus_controller('inline_editable_cell', {'url': url, 'class': class, 'property': property}, {'loading': 'd-none'}) }}>
<span
{{ stimulus_target('inline_editable_cell', 'input') }}
{{ stimulus_action('inline_editable_cell', 'focus', 'focus') | stimulus_action('inline_editable_cell', 'blur', 'blur') }}
contenteditable="true"
style="cursor:pointer"
>
{{ value }}
</span>
<span
{{ stimulus_target('inline_editable_cell', 'handlers') }}
class="d-none"
>
<span {{ stimulus_action('inline_editable_cell', 'update', 'click') }} style="cursor:pointer">{{ ux_icon('bi:check-square', {'class': 'ms-1 text-success'}) }}</span>
<span {{ stimulus_action('inline_editable_cell', 'cancel', 'click') }} style="cursor:pointer">{{ ux_icon('bi:x-square', {'class': 'text-danger'}) }}</span>
</span>
<div
{{ stimulus_target('inline_editable_cell', 'spinner') }}
class="spinner-border spinner-border-sm text-secondary d-none" role="status"
>
<span class="visually-hidden">{{ 'Updating' | trans }}...</span>
</div>
<div
{{ stimulus_target('inline_editable_cell', 'warner') }}
class="d-none text-danger"
>
{{ ux_icon('bi:exclamation-triangle') }} <span class="visually-hidden">{{ 'Error' | trans }}!</span>
</div>
</div>
{% endblock %}
Almost done... next example contains the Stimulus controller where some magic happens. It seems complicated, but is only to control when show or hide the targets to get a good UX/UI. import { Controller } from '@hotwired/stimulus';
/* stimulusFetch: 'lazy' */
export default class extends Controller {
static classes = [ 'loading' ]
static targets = [ 'input', 'handlers', 'spinner', 'warner' ]
static values = {
url: String,
class: String,
property: String,
}
#content = '';
connect() {
console.log('hello from inline_editable_cell controller', this.urlValue, this.classValue, this.propertyValue);
this.#content = this.inputTarget.textContent.trim();
}
focus() {
console.log('focus in', this.#content, this.inputTarget.textContent);
this.handlersTarget.classList.remove(this.loadingClass);
this.spinnerTarget.classList.add(this.loadingClass);
this.warnerTarget.classList.add(this.loadingClass);
}
blur() {
console.log('focus out', this.#content, this.inputTarget.textContent);
setTimeout(() => this.handlersTarget.classList.add(this.loadingClass), 100); // TODO be careful here, can be weird and end with an unexpected behaviour
if (this.#content !== this.inputTarget.textContent.trim()) {
// change detected
console.log('change detected');
this.#content = this.inputTarget.textContent.trim();
this.update();
}
}
async update() {
console.log('update button clicked');
this.inputTarget.classList.add(this.loadingClass);
this.handlersTarget.classList.add(this.loadingClass);
this.spinnerTarget.classList.remove(this.loadingClass);
this.warnerTarget.classList.add(this.loadingClass);
try {
const data = new FormData();
data.append('class', this.classValue);
data.append('property', this.propertyValue);
data.append('value', this.inputTarget.textContent.trim());
const response = await fetch(this.urlValue, {
method: 'POST',
body: data,
});
if (!response.ok) {
this.spinnerTarget.classList.add(this.loadingClass);
this.warnerTarget.classList.remove(this.loadingClass);
}
const json = await response.json();
if (json.hasOwnProperty('success') && json.success === true) {
this.#content = this.inputTarget.textContent.trim();
this.inputTarget.classList.remove(this.loadingClass);
this.handlersTarget.classList.add(this.loadingClass);
this.spinnerTarget.classList.add(this.loadingClass);
this.warnerTarget.classList.add(this.loadingClass);
} else {
this.inputTarget.classList.add(this.loadingClass);
this.handlersTarget.classList.add(this.loadingClass);
this.spinnerTarget.classList.add(this.loadingClass);
this.warnerTarget.classList.remove(this.loadingClass);
}
console.log('response received', response, json);
} catch (error) {
this.inputTarget.classList.add(this.loadingClass);
this.handlersTarget.classList.add(this.loadingClass);
this.spinnerTarget.classList.add(this.loadingClass);
this.warnerTarget.classList.remove(this.loadingClass);
console.error('error message response', error.message);
}
}
cancel() {
console.log('cancel button clicked');
this.inputTarget.textContent = this.#content;
this.handlersTarget.classList.add(this.loadingClass);
this.spinnerTarget.classList.add(this.loadingClass);
this.warnerTarget.classList.add(this.loadingClass);
}
} And finally we need to implement a Symfony controller to accept the POST call and manage it <?php
namespace App\Controller\Main;
use App\Manager\AsyncInlineEntityManager;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
final class InternalApiController extends AbstractController
{
#[IsGranted('ROLE_ADMIN)]
#[Route('/secured/api/{id}/edit-inline-field', name: 'app_internal_api_edit_inline_field', methods: [Request::METHOD_POST])]
public function editInlineField(Request $request, AsyncInlineEntityManager $asyncInlineEntityManager, int $id): JsonResponse
{
return $this->json($asyncInlineEntityManager->getResponseArrayFromRequestAndEntityId($request, $id));
}
} A lot of bad cases to manage here <?php
namespace App\Manager;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Symfony\Component\HttpFoundation\Request;
final readonly class AsyncInlineEntityManager
{
public function __construct(
private EntityManagerInterface $entityManager,
) {
}
public function getResponseArrayFromRequestAndEntityId(Request $request, int $id): array
{
$success = true;
$code = 0;
$response = [
'id' => $id,
'class' => $request->request->get('class'),
'property' => $request->request->get('property'),
];
if (!$request->request->get('class') || !$request->request->get('property') || !$request->request->get('value')) {
$success = false;
$code = 1;
} else {
$entityFqcn = $request->request->get('class');
if (!class_exists($entityFqcn)) {
$success = false;
$code = 2;
} else {
$repository = $this->entityManager->getRepository($entityFqcn);
if (!$repository instanceof EntityRepository) {
$success = false;
$code = 3;
} else {
$object = $repository->find($id);
if (!$object) {
$success = false;
$code = 4;
} else {
$setterMethodName = sprintf('%s%s', 'set', ucfirst($request->request->get('property')));
if (!$this->hasPublicSetterProperty($entityFqcn, $setterMethodName)) {
$success = false;
$code = 5;
} else {
$object->$setterMethodName($request->request->get('value'));
$this->entityManager()->flush();
}
}
}
}
}
$response['success'] = $success;
$response['code'] = $code;
return $response;
}
private function hasPublicSetterProperty(string $fqcn, string $setterMethodName): bool
{
try {
$reflection = new \ReflectionClass($fqcn);
if (!$reflection->hasMethod($setterMethodName)) {
return false;
}
$property = $reflection->getMethod($setterMethodName);
return $property->isPublic();
} catch (\ReflectionException) {
return false;
}
}
} |
Beta Was this translation helpful? Give feedback.
-
As far as I tested it, I've found 3 caveats that needs to be improved or fixed. First one is related with the Javascript blur event when user clicks on update icon becuase the blur event hides the target before to fire the clic event. Second one is how to deal with other input types that are not strings, because when the edited field is an integer (for example), now we are executing a And the last one is how to manage the entity property Symfony validation constraints during the AJAX response. |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
-
Hey folks.
I need to find a solution to enable inline editing table cells. I remember that Sonata uses this old library https://vitalets.github.io/x-editable/
It is possible to find a workaround or approach with Turbo & Stimulus controllers to achieve something similar?
Beta Was this translation helpful? Give feedback.
All reactions