Add NPC database table with server-side DataTable and per-NPC game notes
- Npc entity (village spreadsheet columns) + game_notes text field for
the DM's live play notes, plus updated_at touched on every change
- Race / VillageStatus / BloodCartInvolvement / MysteryImportance as
string-backed PHP enums, rendered as dropdowns on the edit form
- /npcs: server-side DataTables (paging, global search and column sort
all run in the backend via NpcRepository::findForDataTable); name cell
links to the edit page, game notes shown truncated
- /npcs/{id}: Bootstrap-themed edit form (all fields + a large game
notes textarea); Symfony forms now use the bootstrap_5 theme
- app.js: generic server-side DataTable wiring driven by data-* on the
<table>/<th> (data-datatable-src, data-row-url, data-format)
- app:npc:import console command - upserts from data/npcs.csv by
(family, name), never overwrites game_notes; --purge / --dry-run
- data/npcs.csv: 180 villagers exported from the working spreadsheet
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\Npc;
|
||||
use App\Enum\BloodCartInvolvement;
|
||||
use App\Enum\MysteryImportance;
|
||||
use App\Enum\Race;
|
||||
use App\Enum\VillageStatus;
|
||||
use App\Repository\NpcRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:npc:import',
|
||||
description: 'Import / refresh NPCs from a CSV export of the village spreadsheet.',
|
||||
)]
|
||||
class ImportNpcsCommand extends Command
|
||||
{
|
||||
/** CSV header => setter-ish key. */
|
||||
private const COLUMNS = [
|
||||
'Family', 'Name', 'Age', 'Race', 'Occupation',
|
||||
'Relationships / Notes', 'Village Status', 'Blood Cart Involvement', 'Mystery Importance',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly NpcRepository $npcs,
|
||||
#[Autowire('%kernel.project_dir%')]
|
||||
private readonly string $projectDir,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addArgument('file', InputArgument::OPTIONAL, 'CSV path', 'data/npcs.csv')
|
||||
->addOption('purge', null, InputOption::VALUE_NONE, 'Delete NPCs not present in the file')
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Report what would change without writing');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$dryRun = (bool) $input->getOption('dry-run');
|
||||
|
||||
$path = $input->getArgument('file');
|
||||
if (!is_file($path) && is_file($this->projectDir.'/'.$path)) {
|
||||
$path = $this->projectDir.'/'.$path;
|
||||
}
|
||||
if (!is_readable($path)) {
|
||||
$io->error(sprintf('File not readable: %s', $path));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$rows = $this->readCsv($path, $io);
|
||||
if (null === $rows) {
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Validate every enum value up front so we fail cleanly, not half-way.
|
||||
$unknown = [];
|
||||
foreach ($rows as $line => $r) {
|
||||
foreach ([
|
||||
[Race::class, $r['Race']],
|
||||
[VillageStatus::class, $r['Village Status']],
|
||||
[BloodCartInvolvement::class, $r['Blood Cart Involvement']],
|
||||
[MysteryImportance::class, $r['Mystery Importance']],
|
||||
] as [$enum, $value]) {
|
||||
if (null === $enum::tryFrom((string) $value)) {
|
||||
$unknown[] = sprintf('row %d: %s has no case for "%s"', $line + 2, $enum, $value);
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($unknown) {
|
||||
$io->error('Unknown enum values - add the case(s) or fix the file:');
|
||||
$io->listing($unknown);
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$existing = [];
|
||||
foreach ($this->npcs->findAll() as $npc) {
|
||||
$existing[$this->key($npc->getFamily(), $npc->getName())] = $npc;
|
||||
}
|
||||
|
||||
$created = $updated = 0;
|
||||
$seen = [];
|
||||
foreach ($rows as $r) {
|
||||
$key = $this->key($r['Family'], $r['Name']);
|
||||
$seen[$key] = true;
|
||||
|
||||
$npc = $existing[$key] ?? null;
|
||||
if (null === $npc) {
|
||||
$npc = (new Npc())->setFamily($r['Family'])->setName($r['Name']);
|
||||
if (!$dryRun) {
|
||||
$this->em->persist($npc);
|
||||
}
|
||||
++$created;
|
||||
} else {
|
||||
++$updated;
|
||||
}
|
||||
|
||||
// NOTE: gameNotes is deliberately never written here.
|
||||
$npc->setAge('' === $r['Age'] ? null : (int) $r['Age'])
|
||||
->setRace(Race::from($r['Race']))
|
||||
->setOccupation($r['Occupation'] ?: null)
|
||||
->setRelationships($r['Relationships / Notes'] ?: null)
|
||||
->setVillageStatus(VillageStatus::from($r['Village Status']))
|
||||
->setBloodCartInvolvement(BloodCartInvolvement::from($r['Blood Cart Involvement']))
|
||||
->setMysteryImportance(MysteryImportance::from($r['Mystery Importance']));
|
||||
}
|
||||
|
||||
$purged = 0;
|
||||
if ($input->getOption('purge')) {
|
||||
foreach ($existing as $key => $npc) {
|
||||
if (!isset($seen[$key])) {
|
||||
if (!$dryRun) {
|
||||
$this->em->remove($npc);
|
||||
}
|
||||
++$purged;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$dryRun) {
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
$io->success(sprintf(
|
||||
'%s: %d created, %d updated%s (%d rows in file).',
|
||||
$dryRun ? 'Dry run' : 'Imported',
|
||||
$created,
|
||||
$updated,
|
||||
$input->getOption('purge') ? ", {$purged} purged" : '',
|
||||
\count($rows),
|
||||
));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, string>>|null
|
||||
*/
|
||||
private function readCsv(string $path, SymfonyStyle $io): ?array
|
||||
{
|
||||
$fh = fopen($path, 'r');
|
||||
$header = fgetcsv($fh, escape: '');
|
||||
if (false === $header) {
|
||||
$io->error('Empty file.');
|
||||
fclose($fh);
|
||||
|
||||
return null;
|
||||
}
|
||||
$header = array_map('trim', $header);
|
||||
$missing = array_diff(self::COLUMNS, $header);
|
||||
if ($missing) {
|
||||
$io->error('Missing columns: '.implode(', ', $missing));
|
||||
fclose($fh);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
while (false !== ($line = fgetcsv($fh, escape: ''))) {
|
||||
if (1 === \count($line) && (null === $line[0] || '' === trim((string) $line[0]))) {
|
||||
continue; // blank line
|
||||
}
|
||||
$assoc = [];
|
||||
foreach ($header as $i => $name) {
|
||||
$assoc[$name] = trim((string) ($line[$i] ?? ''));
|
||||
}
|
||||
if ('' === $assoc['Family'] && '' === $assoc['Name']) {
|
||||
continue;
|
||||
}
|
||||
$rows[] = $assoc;
|
||||
}
|
||||
fclose($fh);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function key(string $family, string $name): string
|
||||
{
|
||||
return mb_strtolower(trim($family).'|'.trim($name));
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,13 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Npc;
|
||||
use App\Form\NpcType;
|
||||
use App\Repository\NpcRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
@@ -10,9 +16,71 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
#[IsGranted('IS_AUTHENTICATED_FULLY')]
|
||||
class NpcController extends AbstractController
|
||||
{
|
||||
#[Route('/npcs', name: 'app_npcs')]
|
||||
#[Route('/npcs', name: 'app_npcs', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
return $this->render('npc/index.html.twig');
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side processing endpoint for the DataTable on /npcs.
|
||||
* https://datatables.net/manual/server-side
|
||||
*/
|
||||
#[Route('/npcs/data', name: 'app_npcs_data', methods: ['GET'])]
|
||||
public function data(Request $request, NpcRepository $repo): JsonResponse
|
||||
{
|
||||
$q = $request->query;
|
||||
$draw = (int) $q->get('draw', 1);
|
||||
$start = (int) $q->get('start', 0);
|
||||
$length = (int) $q->get('length', 25);
|
||||
$search = (string) ($q->all('search')['value'] ?? '');
|
||||
|
||||
$columns = $q->all('columns');
|
||||
$order = $q->all('order')[0] ?? ['column' => 0, 'dir' => 'asc'];
|
||||
$sortKey = $columns[(int) ($order['column'] ?? 0)]['data'] ?? 'family';
|
||||
$sortDir = (string) ($order['dir'] ?? 'asc');
|
||||
|
||||
$result = $repo->findForDataTable($search, $sortKey, $sortDir, $start, $length);
|
||||
|
||||
$data = array_map(static fn (Npc $n) => [
|
||||
'id' => $n->getId(),
|
||||
'family' => $n->getFamily(),
|
||||
'name' => $n->getName(),
|
||||
'age' => $n->getAge(),
|
||||
'race' => $n->getRace()->value,
|
||||
'occupation' => $n->getOccupation(),
|
||||
'relationships' => $n->getRelationships(),
|
||||
'villageStatus' => $n->getVillageStatus()->value,
|
||||
'bloodCartInvolvement' => $n->getBloodCartInvolvement()->value,
|
||||
'mysteryImportance' => $n->getMysteryImportance()->value,
|
||||
'gameNotes' => $n->getGameNotes(),
|
||||
'updatedAt' => $n->getUpdatedAt()?->format('Y-m-d H:i'),
|
||||
], $result['data']);
|
||||
|
||||
return $this->json([
|
||||
'draw' => $draw,
|
||||
'recordsTotal' => $result['recordsTotal'],
|
||||
'recordsFiltered' => $result['recordsFiltered'],
|
||||
'data' => $data,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/npcs/{id}', name: 'app_npc_edit', requirements: ['id' => '\d+'], methods: ['GET', 'POST'])]
|
||||
public function edit(Request $request, Npc $npc, EntityManagerInterface $em): Response
|
||||
{
|
||||
$form = $this->createForm(NpcType::class, $npc);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$em->flush();
|
||||
$this->addFlash('success', sprintf('Saved %s.', $npc->getName()));
|
||||
|
||||
return $this->redirectToRoute('app_npc_edit', ['id' => $npc->getId()]);
|
||||
}
|
||||
|
||||
return $this->render('npc/edit.html.twig', [
|
||||
'npc' => $npc,
|
||||
'form' => $form,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Enum\BloodCartInvolvement;
|
||||
use App\Enum\MysteryImportance;
|
||||
use App\Enum\Race;
|
||||
use App\Enum\VillageStatus;
|
||||
use App\Repository\NpcRepository;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Entity(repositoryClass: NpcRepository::class)]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_npc_family_name', columns: ['family', 'name'])]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class Npc
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 120)]
|
||||
#[Assert\NotBlank]
|
||||
private string $family = '';
|
||||
|
||||
#[ORM\Column(length: 160)]
|
||||
#[Assert\NotBlank]
|
||||
private string $name = '';
|
||||
|
||||
#[ORM\Column(type: Types::SMALLINT, nullable: true)]
|
||||
#[Assert\PositiveOrZero]
|
||||
private ?int $age = null;
|
||||
|
||||
#[ORM\Column(enumType: Race::class)]
|
||||
private Race $race = Race::Human;
|
||||
|
||||
#[ORM\Column(length: 160, nullable: true)]
|
||||
private ?string $occupation = null;
|
||||
|
||||
/** Free text - the spreadsheet's "Relationships / Notes" column. */
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
private ?string $relationships = null;
|
||||
|
||||
#[ORM\Column(enumType: VillageStatus::class)]
|
||||
private VillageStatus $villageStatus = VillageStatus::Established;
|
||||
|
||||
#[ORM\Column(enumType: BloodCartInvolvement::class)]
|
||||
private BloodCartInvolvement $bloodCartInvolvement = BloodCartInvolvement::None;
|
||||
|
||||
#[ORM\Column(enumType: MysteryImportance::class)]
|
||||
private MysteryImportance $mysteryImportance = MysteryImportance::Low;
|
||||
|
||||
/** DM's live notes, written during play. Never touched by the importer. */
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
private ?string $gameNotes = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||
private ?\DateTimeImmutable $updatedAt = null;
|
||||
|
||||
#[ORM\PrePersist]
|
||||
#[ORM\PreUpdate]
|
||||
public function touch(): void
|
||||
{
|
||||
$this->updatedAt = new \DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getFamily(): string
|
||||
{
|
||||
return $this->family;
|
||||
}
|
||||
|
||||
public function setFamily(string $family): static
|
||||
{
|
||||
$this->family = $family;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function setName(string $name): static
|
||||
{
|
||||
$this->name = $name;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAge(): ?int
|
||||
{
|
||||
return $this->age;
|
||||
}
|
||||
|
||||
public function setAge(?int $age): static
|
||||
{
|
||||
$this->age = $age;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getRace(): Race
|
||||
{
|
||||
return $this->race;
|
||||
}
|
||||
|
||||
public function setRace(Race $race): static
|
||||
{
|
||||
$this->race = $race;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getOccupation(): ?string
|
||||
{
|
||||
return $this->occupation;
|
||||
}
|
||||
|
||||
public function setOccupation(?string $occupation): static
|
||||
{
|
||||
$this->occupation = $occupation;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getRelationships(): ?string
|
||||
{
|
||||
return $this->relationships;
|
||||
}
|
||||
|
||||
public function setRelationships(?string $relationships): static
|
||||
{
|
||||
$this->relationships = $relationships;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getVillageStatus(): VillageStatus
|
||||
{
|
||||
return $this->villageStatus;
|
||||
}
|
||||
|
||||
public function setVillageStatus(VillageStatus $villageStatus): static
|
||||
{
|
||||
$this->villageStatus = $villageStatus;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getBloodCartInvolvement(): BloodCartInvolvement
|
||||
{
|
||||
return $this->bloodCartInvolvement;
|
||||
}
|
||||
|
||||
public function setBloodCartInvolvement(BloodCartInvolvement $bloodCartInvolvement): static
|
||||
{
|
||||
$this->bloodCartInvolvement = $bloodCartInvolvement;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getMysteryImportance(): MysteryImportance
|
||||
{
|
||||
return $this->mysteryImportance;
|
||||
}
|
||||
|
||||
public function setMysteryImportance(MysteryImportance $mysteryImportance): static
|
||||
{
|
||||
$this->mysteryImportance = $mysteryImportance;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getGameNotes(): ?string
|
||||
{
|
||||
return $this->gameNotes;
|
||||
}
|
||||
|
||||
public function setGameNotes(?string $gameNotes): static
|
||||
{
|
||||
$this->gameNotes = $gameNotes;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUpdatedAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->updatedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
enum BloodCartInvolvement: string
|
||||
{
|
||||
case None = 'None';
|
||||
case Donor = 'Donor';
|
||||
case OccasionalDonor = 'Occasional donor';
|
||||
case BloodCollector = 'Blood collector';
|
||||
case BloodCartDriver = 'Blood-cart driver';
|
||||
case CouncilOversight = 'Council oversight';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
enum MysteryImportance: string
|
||||
{
|
||||
case Low = 'Low';
|
||||
case Medium = 'Medium';
|
||||
case High = 'High';
|
||||
case Critical = 'Critical';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
enum Race: string
|
||||
{
|
||||
case Human = 'Human';
|
||||
case Dwarf = 'Dwarf';
|
||||
case Elf = 'Elf';
|
||||
case HalfElf = 'Half-Elf';
|
||||
case Halfling = 'Halfling';
|
||||
case Gnome = 'Gnome';
|
||||
case HalfOrc = 'Half-Orc';
|
||||
case Tiefling = 'Tiefling';
|
||||
case Dragonborn = 'Dragonborn';
|
||||
case Other = 'Other';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enum;
|
||||
|
||||
enum VillageStatus: string
|
||||
{
|
||||
case Established = 'Established villager';
|
||||
case Young = 'Young villager';
|
||||
case Adopted = 'Adopted villager';
|
||||
case NewlyEligibleDonor = 'Newly eligible donor';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Entity\Npc;
|
||||
use App\Enum\BloodCartInvolvement;
|
||||
use App\Enum\MysteryImportance;
|
||||
use App\Enum\Race;
|
||||
use App\Enum\VillageStatus;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\EnumType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class NpcType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$enum = static fn (string $class) => [
|
||||
'class' => $class,
|
||||
'choice_label' => static fn ($case) => $case->value,
|
||||
];
|
||||
|
||||
$builder
|
||||
->add('family', TextType::class)
|
||||
->add('name', TextType::class)
|
||||
->add('age', IntegerType::class, ['required' => false])
|
||||
->add('race', EnumType::class, $enum(Race::class))
|
||||
->add('occupation', TextType::class, ['required' => false])
|
||||
->add('relationships', TextareaType::class, [
|
||||
'required' => false,
|
||||
'label' => 'Relationships / Notes',
|
||||
'attr' => ['rows' => 3],
|
||||
])
|
||||
->add('villageStatus', EnumType::class, $enum(VillageStatus::class))
|
||||
->add('bloodCartInvolvement', EnumType::class, $enum(BloodCartInvolvement::class))
|
||||
->add('mysteryImportance', EnumType::class, $enum(MysteryImportance::class))
|
||||
->add('gameNotes', TextareaType::class, [
|
||||
'required' => false,
|
||||
'label' => 'Game notes',
|
||||
'attr' => ['rows' => 10, 'placeholder' => 'What happened with this NPC during play…'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults(['data_class' => Npc::class]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Npc;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Npc>
|
||||
*/
|
||||
class NpcRepository extends ServiceEntityRepository
|
||||
{
|
||||
/** Fields the DataTable may sort on (column key => DQL field). */
|
||||
public const SORTABLE = [
|
||||
'family' => 'n.family',
|
||||
'name' => 'n.name',
|
||||
'age' => 'n.age',
|
||||
'race' => 'n.race',
|
||||
'occupation' => 'n.occupation',
|
||||
'villageStatus' => 'n.villageStatus',
|
||||
'bloodCartInvolvement' => 'n.bloodCartInvolvement',
|
||||
'mysteryImportance' => 'n.mysteryImportance',
|
||||
'updatedAt' => 'n.updatedAt',
|
||||
];
|
||||
|
||||
/** Fields the global search box matches against (LIKE, OR-ed). */
|
||||
private const SEARCHABLE = [
|
||||
'n.family', 'n.name', 'n.occupation', 'n.relationships', 'n.gameNotes',
|
||||
'n.race', 'n.villageStatus', 'n.bloodCartInvolvement', 'n.mysteryImportance',
|
||||
];
|
||||
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Npc::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side data for DataTables.
|
||||
*
|
||||
* @return array{data: list<Npc>, recordsTotal: int, recordsFiltered: int}
|
||||
*/
|
||||
public function findForDataTable(
|
||||
?string $search,
|
||||
string $sortKey,
|
||||
string $sortDir,
|
||||
int $start,
|
||||
int $length,
|
||||
): array {
|
||||
$sortField = self::SORTABLE[$sortKey] ?? 'n.family';
|
||||
$sortDir = strtolower($sortDir) === 'desc' ? 'DESC' : 'ASC';
|
||||
$length = $length < 0 ? 200 : min($length, 200);
|
||||
|
||||
$qb = $this->createQueryBuilder('n');
|
||||
|
||||
if (null !== $search && '' !== trim($search)) {
|
||||
$ors = [];
|
||||
foreach (self::SEARCHABLE as $i => $field) {
|
||||
$ors[] = $field.' LIKE :s'.$i;
|
||||
$qb->setParameter('s'.$i, '%'.trim($search).'%');
|
||||
}
|
||||
$qb->andWhere('('.implode(' OR ', $ors).')');
|
||||
}
|
||||
|
||||
$filtered = (int) (clone $qb)->select('COUNT(n.id)')->getQuery()->getSingleScalarResult();
|
||||
|
||||
$rows = $qb
|
||||
->orderBy($sortField, $sortDir)
|
||||
->addOrderBy('n.name', 'ASC')
|
||||
->setFirstResult(max(0, $start))
|
||||
->setMaxResults($length)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
return [
|
||||
'data' => $rows,
|
||||
'recordsTotal' => $this->count([]),
|
||||
'recordsFiltered' => $filtered,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user