taktik-nette
This commit is contained in:
commit
8e2cd6ca68
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
vendor
|
||||
log
|
||||
.phpunit.result.cache
|
||||
temp
|
||||
.phpcs-cache
|
||||
|
11
Dockerfile.fpm
Normal file
11
Dockerfile.fpm
Normal file
@ -0,0 +1,11 @@
|
||||
FROM php:8.3-fpm
|
||||
|
||||
RUN apt-get update && apt-get install -y nodejs npm unzip libzip-dev && rm -rf /var/cache/apt/*
|
||||
RUN curl -sSL https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions -o - | sh -s \
|
||||
opentelemetry-php/ext-opentelemetry@main opcache zip grpc intl calendar pdo_mysql mysqli redis
|
||||
RUN curl -sS https://getcomposer.org/installer | php && mv composer.phar /usr/local/bin/composer
|
||||
RUN composer self-update
|
||||
RUN usermod -a -G www-data root
|
||||
RUN mkdir -p /var/www/html
|
||||
RUN chown -R www-data:www-data /var/www/html
|
||||
WORKDIR /var/www/html/
|
3
Dockerfile.nginx
Normal file
3
Dockerfile.nginx
Normal file
@ -0,0 +1,3 @@
|
||||
FROM nginx:1.25.3-alpine
|
||||
COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
0
Dockerfile.static
Normal file
0
Dockerfile.static
Normal file
64
README.md
Normal file
64
README.md
Normal file
@ -0,0 +1,64 @@
|
||||
How to start with development
|
||||
===
|
||||
|
||||
* **Requirements:**
|
||||
* bash
|
||||
* Docker with Docker Compose (v2) command
|
||||
|
||||
* **Run:**
|
||||
```bash
|
||||
develop.sh
|
||||
```
|
||||
* Enjoy your coffee ;-)
|
||||
* Nette will serve the web at [http://localhost:8000].
|
||||
|
||||
---
|
||||
|
||||
Tests
|
||||
---
|
||||
Run the following command to execute the tests:
|
||||
|
||||
```bash
|
||||
docker compose exec php-fpm vendor/bin/phpunit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
PHPStan & CS
|
||||
---
|
||||
|
||||
* Run PHP CodeSniffer:
|
||||
```bash
|
||||
docker compose exec php-fpm vendor/bin/phpcs
|
||||
```
|
||||
* Run PHPStan with increased memory limit:
|
||||
```bash
|
||||
docker compose exec php-fpm vendor/bin/phpstan --memory-limit=2G
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Containers
|
||||
---
|
||||
|
||||
* `php-fpm` - FPM and additional tooling
|
||||
* `nginx` - Serves static content, proxies requests to FPM
|
||||
* `mariadb` - Database
|
||||
|
||||
---
|
||||
|
||||
TODO
|
||||
---
|
||||
|
||||
* [ ] The current tests are just two unit tests. It definitely needs testing of individual pages, but I haven't had time to set up Codeception (which I would like to use), and the framework itself doesn't offer much.
|
||||
* [ ] Add a monitoring stack (Prometheus, Otel, Loki, Grafana, etc.).
|
||||
* [ ] Integrate Sentry.
|
||||
* [ ] Establish deployment pipelines for dev/test and production environments. Configure production settings and Docker
|
||||
images.
|
||||
* [ ] Integrate CI/CD.
|
||||
|
||||
---
|
||||
|
||||
Notes
|
||||
---
|
||||
* Saving filters into session makes this app very unusable (back button etc.)
|
51
app/Bootstrap.php
Normal file
51
app/Bootstrap.php
Normal file
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App;
|
||||
|
||||
use Nette;
|
||||
use Nette\Bootstrap\Configurator;
|
||||
use Tracy\Debugger;
|
||||
|
||||
class Bootstrap
|
||||
{
|
||||
private Configurator $configurator;
|
||||
private string $rootDir;
|
||||
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->rootDir = dirname(__DIR__);
|
||||
$this->configurator = new Configurator();
|
||||
$this->configurator->setTempDirectory($this->rootDir . '/temp');
|
||||
}
|
||||
|
||||
|
||||
public function bootWebApplication(): Nette\DI\Container
|
||||
{
|
||||
$this->initializeEnvironment();
|
||||
$this->setupContainer();
|
||||
|
||||
return $this->configurator->createContainer();
|
||||
}
|
||||
|
||||
public function initializeEnvironment(): void
|
||||
{
|
||||
//$this->configurator->setDebugMode('secret@23.75.345.200'); // enable for your remote IP
|
||||
//$this->configurator->enableTracy($this->rootDir . '/log');
|
||||
|
||||
$this->configurator->createRobotLoader()
|
||||
->addDirectory(__DIR__)
|
||||
->register();
|
||||
}
|
||||
|
||||
|
||||
private function setupContainer(): void
|
||||
{
|
||||
$configDir = $this->rootDir . '/config';
|
||||
$this->configurator->addConfig($configDir . '/common.neon');
|
||||
$this->configurator->addConfig($configDir . '/services.neon');
|
||||
$this->configurator->addConfig($configDir . '/local.neon');
|
||||
}
|
||||
}
|
23
app/Core/RouterFactory.php
Normal file
23
app/Core/RouterFactory.php
Normal file
@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Core;
|
||||
|
||||
use Nette;
|
||||
use Nette\Application\Routers\RouteList;
|
||||
|
||||
final class RouterFactory
|
||||
{
|
||||
use Nette\StaticClass;
|
||||
|
||||
public static function createRouter(): RouteList
|
||||
{
|
||||
$router = new RouteList();
|
||||
$router->addRoute('<presenter>/<action>[/<id>]', [
|
||||
'presenter' => 'Survey',
|
||||
'action' => 'default',
|
||||
]);
|
||||
return $router;
|
||||
}
|
||||
}
|
83
app/Repository/SurveyRepository.php
Normal file
83
app/Repository/SurveyRepository.php
Normal file
@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\UI\DTO\FilterDTO;
|
||||
use App\UI\DTO\OrderDirection;
|
||||
use App\UI\DTO\OrderDTO;
|
||||
use Nette\Database\Explorer;
|
||||
use Nette\Database\Row;
|
||||
use Nette\Database\SqlLiteral;
|
||||
|
||||
class SurveyRepository
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Explorer $explorer
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, Row>
|
||||
*/
|
||||
public function findSurveys(int $limit, int $offset, ?OrderDTO $order, FilterDTO $filter): array
|
||||
{
|
||||
if ($order === null) {
|
||||
$order = new OrderDTO(
|
||||
'survey_id',
|
||||
OrderDirection::DESC
|
||||
);
|
||||
}
|
||||
|
||||
$data = $this->explorer->query(
|
||||
"SELECT
|
||||
survey.id AS survey_id,
|
||||
survey.name AS survey_name,
|
||||
survey.comments,
|
||||
survey.agreement,
|
||||
(
|
||||
SELECT
|
||||
GROUP_CONCAT(i2.interest SEPARATOR ',')
|
||||
FROM interests i2
|
||||
WHERE survey.id = i2.survey_id
|
||||
GROUP BY i2.survey_id
|
||||
) AS interests
|
||||
FROM
|
||||
survey
|
||||
LEFT JOIN
|
||||
interests
|
||||
ON
|
||||
survey.id = interests.survey_id
|
||||
WHERE ?
|
||||
GROUP BY
|
||||
survey.id
|
||||
ORDER BY ? ?
|
||||
LIMIT ?
|
||||
OFFSET ?",
|
||||
$filter->filters,
|
||||
new SqlLiteral($order->column),
|
||||
new SqlLiteral($order->direction->value),
|
||||
$limit,
|
||||
$offset
|
||||
);
|
||||
|
||||
return $data->fetchAll();
|
||||
}
|
||||
|
||||
public function getSuveysCount(FilterDTO $filter): int
|
||||
{
|
||||
$countQuery = $this->explorer->query(
|
||||
"SELECT
|
||||
COUNT(DISTINCT survey.id) AS cnt
|
||||
FROM survey
|
||||
LEFT JOIN
|
||||
interests
|
||||
ON
|
||||
survey.id = interests.survey_id
|
||||
WHERE ?",
|
||||
$filter->filters
|
||||
);
|
||||
return $countQuery->fetchField();
|
||||
}
|
||||
}
|
19
app/UI/@layout.latte
Normal file
19
app/UI/@layout.latte
Normal file
@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
|
||||
<title>{ifset title}{include title|stripHtml} | {/ifset}Nette Web</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div n:foreach="$flashes as $flash" n:class="flash, $flash->type, alert, alert . '-' . $flash->type">{$flash->message}</div>
|
||||
|
||||
{include content}
|
||||
|
||||
{block scripts}
|
||||
<script src="https://unpkg.com/nette-forms@3"></script>
|
||||
{/block}
|
||||
</body>
|
||||
</html>
|
45
app/UI/Accessory/LatteExtension.php
Normal file
45
app/UI/Accessory/LatteExtension.php
Normal file
@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\UI\Accessory;
|
||||
|
||||
use Latte\Extension;
|
||||
use Nette\Localization\Translator;
|
||||
|
||||
final class LatteExtension extends Extension
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Translator $translator
|
||||
) {
|
||||
}
|
||||
|
||||
public function getFilters(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
public function getFunctions(): array
|
||||
{
|
||||
return [
|
||||
'interests' => [$this, 'renderInterests'],
|
||||
];
|
||||
}
|
||||
|
||||
public function renderInterests(?string $interests): string
|
||||
{
|
||||
if ($interests === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$explodedInterests = explode(',', $interests);
|
||||
$rtVal = [];
|
||||
|
||||
foreach ($explodedInterests as $interest) {
|
||||
$rtVal[] = $this->translator->translate('survey.interests.' . $interest);
|
||||
}
|
||||
|
||||
return implode(',', $rtVal);
|
||||
}
|
||||
}
|
31
app/UI/DTO/FilterDTO.php
Normal file
31
app/UI/DTO/FilterDTO.php
Normal file
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\UI\DTO;
|
||||
|
||||
class FilterDTO
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
*/
|
||||
public function __construct(public readonly array $filters = [])
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $data
|
||||
* @param array<int, string> $acceptFilters
|
||||
* @return FilterDTO
|
||||
*/
|
||||
public static function createFromData(?array $data, array $acceptFilters = []): FilterDTO
|
||||
{
|
||||
if ($data === null) {
|
||||
return new self([]);
|
||||
}
|
||||
|
||||
$result = array_intersect_key($data, array_flip($acceptFilters));
|
||||
|
||||
return new self($result);
|
||||
}
|
||||
}
|
34
app/UI/DTO/OrderDTO.php
Normal file
34
app/UI/DTO/OrderDTO.php
Normal file
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\UI\DTO;
|
||||
|
||||
class OrderDTO
|
||||
{
|
||||
public function __construct(public readonly string $column, public readonly OrderDirection $direction)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $acceptColumns
|
||||
*/
|
||||
public static function createOrReturnNullFromData(?string $order, ?string $direction, array $acceptColumns = []): ?OrderDTO
|
||||
{
|
||||
if ($order === null || $direction === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!in_array($order, $acceptColumns, true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$direction = OrderDirection::tryFrom($direction);
|
||||
|
||||
if ($direction === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new self($order, $direction);
|
||||
}
|
||||
}
|
11
app/UI/DTO/OrderDirection.php
Normal file
11
app/UI/DTO/OrderDirection.php
Normal file
@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\UI\DTO;
|
||||
|
||||
enum OrderDirection: string
|
||||
{
|
||||
case ASC = "ASC";
|
||||
case DESC = "DESC";
|
||||
}
|
20
app/UI/Error/Error4xx/Error4xxPresenter.php
Normal file
20
app/UI/Error/Error4xx/Error4xxPresenter.php
Normal file
@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\UI\Error\Error4xx;
|
||||
|
||||
use Nette;
|
||||
use Nette\Application\Attributes\Requires;
|
||||
|
||||
/**
|
||||
* Handles 4xx HTTP error responses.
|
||||
*/
|
||||
#[Requires(methods: '*', forward: true)]
|
||||
final class Error4xxPresenter extends Nette\Application\UI\Presenter
|
||||
{
|
||||
public function renderDefault(Nette\Application\BadRequestException $exception): void
|
||||
{
|
||||
$this->redirect(':Survey:default');
|
||||
}
|
||||
}
|
27
app/UI/Error/Error5xx/500.phtml
Normal file
27
app/UI/Error/Error5xx/500.phtml
Normal file
@ -0,0 +1,27 @@
|
||||
<!DOCTYPE html><!-- "' --></textarea></script></style></pre></xmp></a></audio></button></canvas></datalist></details></dialog></iframe></listing></meter></noembed></noframes></noscript></optgroup></option></progress></rp></select></table></template></title></video>
|
||||
<meta charset="utf-8">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>Server Error</title>
|
||||
|
||||
<style>
|
||||
#nette-error { all: initial; position: absolute; top: 0; left: 0; right: 0; height: 70vh; min-height: 400px; display: flex; align-items: center; justify-content: center; z-index: 1000 }
|
||||
#nette-error div { all: initial; max-width: 550px; background: white; color: #333; display: block }
|
||||
#nette-error h1 { all: initial; font: bold 50px/1.1 sans-serif; display: block; margin: 40px }
|
||||
#nette-error p { all: initial; font: 20px/1.4 sans-serif; margin: 40px; display: block }
|
||||
#nette-error small { color: gray }
|
||||
</style>
|
||||
|
||||
<div id=nette-error>
|
||||
<div>
|
||||
<h1>Server Error</h1>
|
||||
|
||||
<p>We're sorry! The server encountered an internal error and
|
||||
was unable to complete your request. Please try again later.</p>
|
||||
|
||||
<p><small>error 500</small></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.body.insertBefore(document.getElementById('nette-error'), document.body.firstChild);
|
||||
</script>
|
24
app/UI/Error/Error5xx/503.phtml
Normal file
24
app/UI/Error/Error5xx/503.phtml
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
header('HTTP/1.1 503 Service Unavailable');
|
||||
header('Retry-After: 300'); // 5 minutes in seconds
|
||||
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<meta charset="utf-8">
|
||||
<meta name="robots" content="noindex">
|
||||
<meta name="generator" content="Nette Framework">
|
||||
|
||||
<style>
|
||||
body { color: #333; background: white; width: 500px; margin: 100px auto }
|
||||
h1 { font: bold 47px/1.5 sans-serif; margin: .6em 0 }
|
||||
p { font: 21px/1.5 Georgia,serif; margin: 1.5em 0 }
|
||||
</style>
|
||||
|
||||
<title>Site is temporarily down for maintenance</title>
|
||||
|
||||
<h1>We're Sorry</h1>
|
||||
|
||||
<p>The site is temporarily down for maintenance. Please try again in a few minutes.</p>
|
38
app/UI/Error/Error5xx/Error5xxPresenter.php
Normal file
38
app/UI/Error/Error5xx/Error5xxPresenter.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\UI\Error\Error5xx;
|
||||
|
||||
use Nette;
|
||||
use Nette\Application\Attributes\Requires;
|
||||
use Nette\Application\Responses;
|
||||
use Nette\Http;
|
||||
use Tracy\ILogger;
|
||||
|
||||
/**
|
||||
* Handles uncaught exceptions and errors, and logs them.
|
||||
*/
|
||||
#[Requires(forward: true)]
|
||||
final class Error5xxPresenter implements Nette\Application\IPresenter
|
||||
{
|
||||
public function __construct(
|
||||
private ILogger $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
public function run(Nette\Application\Request $request): Nette\Application\Response
|
||||
{
|
||||
// Log the exception
|
||||
$exception = $request->getParameter('exception');
|
||||
$this->logger->log($exception, ILogger::EXCEPTION);
|
||||
|
||||
// Display a generic error message to the user
|
||||
return new Responses\CallbackResponse(function (Http\IRequest $httpRequest, Http\IResponse $httpResponse): void {
|
||||
if (preg_match('#^text/html(?:;|$)#', (string) $httpResponse->getHeader('Content-Type'))) {
|
||||
require __DIR__ . '/500.phtml';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
35
app/UI/Form/ResultFilteringFactory.php
Normal file
35
app/UI/Form/ResultFilteringFactory.php
Normal file
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\UI\Form;
|
||||
|
||||
use Nette\Application\UI\Form;
|
||||
use Nette\Localization\Translator;
|
||||
|
||||
class ResultFilteringFactory
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Translator $translator,
|
||||
) {
|
||||
}
|
||||
public function create(): Form
|
||||
{
|
||||
$form = new Form();
|
||||
|
||||
$form->setTranslator($this->translator);
|
||||
|
||||
$form->addText('name', 'survey.name');
|
||||
|
||||
$form->addTextArea('comments', 'survey.comments');
|
||||
|
||||
$form->addMultiSelect('interest', 'survey.interests', [
|
||||
'sport' => 'survey.interests.sport',
|
||||
'music' => 'survey.interests.music',
|
||||
'travel' => 'survey.interests.travel',
|
||||
]);
|
||||
|
||||
$form->addSubmit('send', 'survey.search');
|
||||
return $form;
|
||||
}
|
||||
}
|
47
app/UI/Form/SurveyFormFactory.php
Normal file
47
app/UI/Form/SurveyFormFactory.php
Normal file
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\UI\Form;
|
||||
|
||||
use Nette\Application\UI\Form;
|
||||
use Nette\Forms\Form as NetteForm;
|
||||
use Nette\Localization\Translator;
|
||||
|
||||
class SurveyFormFactory
|
||||
{
|
||||
public function __construct(
|
||||
protected readonly Translator $translator,
|
||||
) {
|
||||
}
|
||||
public function create(): Form
|
||||
{
|
||||
$form = new Form();
|
||||
|
||||
$form->setTranslator($this->translator);
|
||||
|
||||
$form->addText('name', 'survey.name')
|
||||
->setRequired('survey.name_required')
|
||||
->addRule(NetteForm::MaxLength, $this->translator->translate('survey.name_is_too_long'), 255);
|
||||
|
||||
|
||||
$form->addTextArea('comments', 'survey.comments');
|
||||
|
||||
$form->addCheckbox('agreement', 'survey.agreement')
|
||||
->setRequired('survey.agreement_required');
|
||||
|
||||
$form->addMultiSelect('interests', 'survey.interests', [
|
||||
'sport' => 'survey.interests.sport',
|
||||
'music' => 'survey.interests.music',
|
||||
'travel' => 'survey.interests.travel',
|
||||
])->setRequired('survey.interests_required')
|
||||
->addRule(NetteForm::MaxLength, $this->translator->translate('survey.interest_is_too_long'), 255);
|
||||
|
||||
|
||||
$form->addSubmit('send', 'survey.send');
|
||||
|
||||
$form->addProtection('survey.protection');
|
||||
|
||||
return $form;
|
||||
}
|
||||
}
|
137
app/UI/Results/ResultsPresenter.php
Normal file
137
app/UI/Results/ResultsPresenter.php
Normal file
@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\UI\Results;
|
||||
|
||||
use App\Repository\SurveyRepository;
|
||||
use App\UI\DTO\FilterDTO;
|
||||
use App\UI\DTO\OrderDTO;
|
||||
use App\UI\Form\ResultFilteringFactory;
|
||||
use Nette\Application\Attributes\Persistent;
|
||||
use Nette\Application\UI\Form;
|
||||
use Nette\Application\UI\Presenter;
|
||||
use Nette\Http\Session;
|
||||
use Nette\Utils\Paginator;
|
||||
use Nubium\Exception\ThisShouldNeverHappenException;
|
||||
|
||||
class ResultsPresenter extends Presenter
|
||||
{
|
||||
#[Persistent]
|
||||
public ?string $order = null;
|
||||
|
||||
#[Persistent]
|
||||
public ?string $direction = null;
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
#[Persistent]
|
||||
public ?array $filter = null;
|
||||
|
||||
protected const SESSION_STORE_KEY = 'survey_results_v1';
|
||||
|
||||
public function __construct(
|
||||
protected readonly SurveyRepository $surveyRepository,
|
||||
protected readonly ResultFilteringFactory $resultFilteringFactory,
|
||||
protected readonly Session $session,
|
||||
) {
|
||||
parent::__construct();
|
||||
$this->restoreStateFromSession();
|
||||
if ($this->order === null) {
|
||||
$this->order = 'survey_id';
|
||||
}
|
||||
if ($this->direction === null) {
|
||||
$this->direction = 'desc';
|
||||
}
|
||||
if ($this->filter === null) {
|
||||
$this->filter = null;
|
||||
}
|
||||
}
|
||||
|
||||
public function renderDefault(int $page = 1): void
|
||||
{
|
||||
$order = OrderDTO::createOrReturnNullFromData($this->order, $this->direction, ['survey_name', 'comments', 'survey_id']);
|
||||
$filter = FilterDTO::createFromData($this->filter, ['name', 'comments', 'agreement', 'interest']);
|
||||
|
||||
$itemsCount = $this->surveyRepository->getSuveysCount($filter);
|
||||
$paginator = new Paginator();
|
||||
$paginator->setItemCount($itemsCount);
|
||||
$paginator->setItemsPerPage(10);
|
||||
$paginator->setPage($page);
|
||||
|
||||
$articles = $this->surveyRepository->findSurveys($paginator->getLength(), $paginator->getOffset(), $order, $filter);
|
||||
|
||||
$this->template->surveys = $articles; // @phpstan-ignore property.notFound
|
||||
$this->template->paginator = $paginator; // @phpstan-ignore property.notFound
|
||||
$this->template->order = $this->order; // @phpstan-ignore property.notFound
|
||||
$this->template->direction = $this->direction; // @phpstan-ignore property.notFound
|
||||
}
|
||||
|
||||
|
||||
protected function createComponentFilteringForm(): Form
|
||||
{
|
||||
$form = $this->resultFilteringFactory->create();
|
||||
|
||||
$form->setDefaults($this->filter ?? []);
|
||||
|
||||
// @phpstan-ignore-next-line assign.propertyType
|
||||
$form->onSuccess[] = function (\Nette\Forms\Form $form, array $values): void {
|
||||
if ($form->isValid()) {
|
||||
$filter = [];
|
||||
if (!empty($values['name'])) {
|
||||
$filter['name'] = $values['name'];
|
||||
}
|
||||
if (!empty($values['agreement'])) {
|
||||
$filter['agreement'] = $values['agreement'];
|
||||
}
|
||||
if (!empty($values['comments'])) {
|
||||
$filter['comments'] = $values['comments'];
|
||||
}
|
||||
|
||||
if (!empty($values['interest'])) {
|
||||
$filter['interest'] = $values['interest'];
|
||||
}
|
||||
|
||||
$this->filter = $filter;
|
||||
$this->storeStateIntoSession();
|
||||
|
||||
$this->redirect('default', [
|
||||
'page' => 1,
|
||||
'filter' => $filter,
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
return $form;
|
||||
}
|
||||
|
||||
|
||||
protected function restoreStateFromSession(): void
|
||||
{
|
||||
$session = $this->session->getSection(self::SESSION_STORE_KEY);
|
||||
if ($session->get('order') !== null) {
|
||||
$this->order = $session->get('order');
|
||||
}
|
||||
if ($session->get('direction') !== null) {
|
||||
$this->direction = $session->get('direction');
|
||||
}
|
||||
if ($session->get('filter') !== null) {
|
||||
$this->filter = $session->get('filter');
|
||||
}
|
||||
}
|
||||
|
||||
protected function storeStateIntoSession(): void
|
||||
{
|
||||
$session = $this->session->getSection(self::SESSION_STORE_KEY);
|
||||
$session->set('order', $this->order);
|
||||
$session->set('direction', $this->direction);
|
||||
$session->set('filter', $this->filter);
|
||||
}
|
||||
|
||||
public function terminate(): void
|
||||
{
|
||||
$this->storeStateIntoSession();
|
||||
parent::terminate();
|
||||
}
|
||||
}
|
63
app/UI/Results/default.latte
Normal file
63
app/UI/Results/default.latte
Normal file
@ -0,0 +1,63 @@
|
||||
{block content}
|
||||
<h1>Ankety</h1>
|
||||
|
||||
<div class="suveys">
|
||||
{control filteringForm}
|
||||
{if count($surveys) == 0}
|
||||
<div class="alert alert-danger">{_('survey.data_not_found')}</div>
|
||||
{else}
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<a n:href="default, 1, order => 'survey_id', direction => $direction === 'ASC' && $order === 'survey_id' ? 'DESC' : 'ASC'">
|
||||
{_('survey.id')} {$order === 'survey_id' ? ($direction === 'ASC' ? '▲' : '▼') : ''}
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a n:href="default, 1, order => 'survey_name', direction => $direction === 'ASC' && $order === 'survey_name' ? 'DESC' : 'ASC'">
|
||||
{_('survey.name')} {$order === 'survey_name' ? ($direction === 'ASC' ? '▲' : '▼') : ''}
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a n:href="default, 1, order => 'comments', direction => $direction === 'ASC' && $order === 'comments' ? 'DESC' : 'ASC'">
|
||||
{_('survey.comments')} {$order === 'comments' ? ($direction === 'ASC' ? '▲' : '▼') : ''}
|
||||
</a>
|
||||
</th>
|
||||
<th>{_('survey.interests')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{foreach $surveys as $survey}
|
||||
<tr >
|
||||
<td>{$survey->survey_id}</td>
|
||||
<td>{$survey->survey_name}</td>
|
||||
<td>{$survey->comments}</td>
|
||||
<td>{interests($survey->interests)}</td>
|
||||
</tr>
|
||||
{/foreach}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{if count($surveys) > 0}
|
||||
<div class="pagination">
|
||||
{if !$paginator->isFirst()}
|
||||
<a n:href="default, 1">{_('pagination.first')}</a>
|
||||
|
|
||||
<a n:href="default, $paginator->page-1">{_('pagination.prev')}</a>
|
||||
|
|
||||
{/if}
|
||||
|
||||
Stránka {$paginator->getPage()} z {$paginator->getPageCount()}
|
||||
|
||||
{if !$paginator->isLast()}
|
||||
|
|
||||
<a n:href="default, $paginator->getPage() + 1">{_('pagination.next')}</a>
|
||||
|
|
||||
<a n:href="default, $paginator->getPageCount()">{_('pagination.last')}</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/block}
|
69
app/UI/Survey/SurveyPresenter.php
Normal file
69
app/UI/Survey/SurveyPresenter.php
Normal file
@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\UI\Survey;
|
||||
|
||||
use Nette;
|
||||
use App\UI\Form\SurveyFormFactory;
|
||||
use Nette\Application\UI\Form;
|
||||
use Nubium\Exception\ThisShouldNeverHappenException;
|
||||
|
||||
class SurveyPresenter extends Nette\Application\UI\Presenter
|
||||
{
|
||||
public function __construct(
|
||||
protected readonly SurveyFormFactory $surveyFormFactor,
|
||||
protected readonly Nette\Database\Explorer $database,
|
||||
protected readonly Nette\Localization\Translator $translator
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function renderDefault(): void
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $values
|
||||
*/
|
||||
public function processSurveyForm(Nette\Forms\Form $form, array $values): void
|
||||
{
|
||||
if ($form->isValid()) {
|
||||
$this->database->beginTransaction();
|
||||
|
||||
try {
|
||||
$recordId = $this->database->table('survey')->insert(
|
||||
[
|
||||
'name' => $values['name'],
|
||||
'comments' => $values['comments'],
|
||||
'agreement' => $values['agreement'],
|
||||
]
|
||||
);
|
||||
|
||||
foreach ($values['interests'] as $value) {
|
||||
$this->database->table('interests')->insert(
|
||||
[
|
||||
'survey_id' => $recordId,
|
||||
'interest' => $value,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
$this->database->commit();
|
||||
|
||||
$this->flashMessage($this->translator->translate('survey.successfullyFilled'), 'success');
|
||||
$this->redirect('this');
|
||||
} catch (\PDOException $e) {
|
||||
$this->database->rollback(); // TODO: Tady bych dal log do sentry a rekl uziteli at to zkusi pozdeji ;-)
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function createComponentSurveyForm(): Nette\Application\UI\Form
|
||||
{
|
||||
$form = $this->surveyFormFactor->create();
|
||||
$form->onSuccess[] = [$this, 'processSurveyForm']; // @phpstan-ignore assign.propertyType
|
||||
return $form;
|
||||
}
|
||||
}
|
4
app/UI/Survey/default.latte
Normal file
4
app/UI/Survey/default.latte
Normal file
@ -0,0 +1,4 @@
|
||||
{block content}
|
||||
<h1>{_('survey.header')}</h1>
|
||||
{control surveyForm}
|
||||
{/block}
|
0
app/lang/messages.cs.neon
Normal file
0
app/lang/messages.cs.neon
Normal file
4
app/lang/pagination.cs.neon
Normal file
4
app/lang/pagination.cs.neon
Normal file
@ -0,0 +1,4 @@
|
||||
last: Poslední
|
||||
next: Následující
|
||||
prev: Předcházející
|
||||
first: První
|
18
app/lang/survey.cs.neon
Normal file
18
app/lang/survey.cs.neon
Normal file
@ -0,0 +1,18 @@
|
||||
name: Jméno
|
||||
comments: Komentáře
|
||||
agreement: Souhlasím s podmínkami
|
||||
interests: Zájmy
|
||||
header: Dotazník
|
||||
interests.sport: Sport
|
||||
interests.music: Hudba
|
||||
interests.travel: Cestování
|
||||
send: Odeslat
|
||||
name_required: Vyplňte prosím jméno
|
||||
interests_required: Vypňte prosím zájmy
|
||||
agreement_required: Musíte souhlasit s podmínkami
|
||||
successfullyFilled: "Anketa byla úspěšně vyplňena, děkujeme!"
|
||||
search: Hledat
|
||||
id: ID
|
||||
name_is_too_long: "Jméno je na naši anketu příliš dlouhé. Bohužel :-("
|
||||
interest_is_too_long: "Váš zájem náš systém neschroupe. Pokud si myslíte že to je chyba, prosím obraťte se na naši podporu"
|
||||
data_not_found: "Data nebyly nalezeny"
|
42
composer.json
Normal file
42
composer.json
Normal file
@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "nette/web-project",
|
||||
"description": "Nette: Standard Web Project",
|
||||
"keywords": ["nette"],
|
||||
"type": "project",
|
||||
"license": ["MIT", "BSD-3-Clause", "GPL-2.0-only", "GPL-3.0-only"],
|
||||
"require": {
|
||||
"php": ">= 8.1",
|
||||
"nette/application": "^3.2.3",
|
||||
"nette/bootstrap": "^3.2",
|
||||
"nette/caching": "^3.3",
|
||||
"nette/database": "^3.2",
|
||||
"nette/di": "^3.2",
|
||||
"nette/forms": "^3.2",
|
||||
"nette/http": "^3.3",
|
||||
"nette/mail": "^4.0",
|
||||
"nette/robot-loader": "^4.0",
|
||||
"nette/security": "^3.2",
|
||||
"nette/utils": "^4.0",
|
||||
"latte/latte": "^3.0",
|
||||
"tracy/tracy": "^2.10",
|
||||
"contributte/translation": "^2.0",
|
||||
"nubium/this-should-never-happen-exception": "^1.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "app"
|
||||
}
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
"dealerdirect/phpcodesniffer-composer-installer": true
|
||||
}
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^11.5",
|
||||
"slevomat/coding-standard": "^8.15",
|
||||
"squizlabs/php_codesniffer": "^3.11",
|
||||
"phpstan/phpstan": "^2.1"
|
||||
}
|
||||
}
|
4090
composer.lock
generated
Normal file
4090
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
config/common.neon
Normal file
44
config/common.neon
Normal file
@ -0,0 +1,44 @@
|
||||
parameters:
|
||||
|
||||
extensions:
|
||||
translation: Contributte\Translation\DI\TranslationExtension
|
||||
|
||||
translation:
|
||||
locales:
|
||||
whitelist: [cs]
|
||||
default: cs
|
||||
fallback: [cs]
|
||||
dirs:
|
||||
- %appDir%/lang
|
||||
returnOriginalMessage: true # to not translate undefined messages, default is true
|
||||
|
||||
|
||||
application:
|
||||
errorPresenter:
|
||||
4xx: Error:Error4xx
|
||||
5xx: Error:Error5xx
|
||||
mapping: App\UI\*\**Presenter
|
||||
|
||||
database:
|
||||
dsn: 'mysql:host=mariadb;dbname=survey;charset=utf8mb4'
|
||||
user: nette
|
||||
password: nette
|
||||
|
||||
latte:
|
||||
strictTypes: yes
|
||||
strictParsing: yes
|
||||
extensions:
|
||||
- App\UI\Accessory\LatteExtension
|
||||
|
||||
session:
|
||||
autoStart: true
|
||||
save_handler: redis
|
||||
save_path: 'tcp://redis?timeout=3'
|
||||
|
||||
services:
|
||||
- App\Repository\SurveyRepository
|
||||
|
||||
di:
|
||||
export:
|
||||
parameters: no
|
||||
tags: no
|
5
config/local.neon
Normal file
5
config/local.neon
Normal file
@ -0,0 +1,5 @@
|
||||
parameters:
|
||||
debugMode: true
|
||||
tracy:
|
||||
showDebugBar: true
|
||||
strictMode: true
|
10
config/services.neon
Normal file
10
config/services.neon
Normal file
@ -0,0 +1,10 @@
|
||||
services:
|
||||
- App\Core\RouterFactory::createRouter
|
||||
|
||||
search:
|
||||
- in: %appDir%
|
||||
classes:
|
||||
- *Facade
|
||||
- *Factory
|
||||
- *Repository
|
||||
- *Service
|
23
develop.sh
Executable file
23
develop.sh
Executable file
@ -0,0 +1,23 @@
|
||||
|
||||
#!/usr/bin/env sh
|
||||
|
||||
|
||||
set -ex
|
||||
|
||||
|
||||
docker compose up --build -d
|
||||
|
||||
# composer
|
||||
docker compose exec -it php-fpm composer install
|
||||
# fix permissions (TODO: fix-me) - by user in dockerfile
|
||||
docker compose exec -it php-fpm /bin/sh -c "mkdir -p /var/www/html/temp && chown -R www-data:www-data /var/www/html/temp"
|
||||
|
||||
|
||||
docker compose up -d mariadb --wait
|
||||
cat migrations/*.sql | docker compose exec -T mariadb mysql -pnette survey
|
||||
|
||||
# run containers (and build again)
|
||||
if [ "$NO_EXECUTE" != "1" ]; then
|
||||
docker compose up
|
||||
fi
|
||||
|
59
docker-compose.yml
Normal file
59
docker-compose.yml
Normal file
@ -0,0 +1,59 @@
|
||||
services:
|
||||
nginx:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./Dockerfile.nginx
|
||||
ports:
|
||||
- '8000:80'
|
||||
volumes:
|
||||
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
|
||||
- ./:/var/www/html/
|
||||
links:
|
||||
- php-fpm
|
||||
php-fpm:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./Dockerfile.fpm
|
||||
volumes:
|
||||
- ./:/var/www/html/
|
||||
environment:
|
||||
- OTEL_LOG_LEVEL=debug
|
||||
- OTEL_TRACES_EXPORTER=otlp
|
||||
- OTEL_METRICS_EXPORTER=otlp
|
||||
- OTEL_LOGS_EXPORTER=otlp
|
||||
- OTEL_PHP_AUTOLOAD_ENABLED=true
|
||||
- OTEL_PHP_TRACES_PROCESSOR=simple
|
||||
- OTEL_PHP_LOG_DESTINATION=stderr
|
||||
- OTEL_EXPORTER_OTLP_PROTOCOL=grpc
|
||||
- OTEL_EXPORTER_OTLP_ENDPOINT=http://otelcol:4317
|
||||
- DB_CONNECTION=mysql
|
||||
- DB_URL=mysql://laravel:laravel@mariadb:3306/postsystem
|
||||
- REDIS_HOST=redis
|
||||
- SESSION_DRIVER=file
|
||||
- APP_DEBUG=true
|
||||
- APP_ENV=local
|
||||
- APP_KEY=base64:yFEh+jTepLsusyVKLmFY3ukDfJrshbB3J6jVzVk1guw=
|
||||
links:
|
||||
- mariadb
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
dns:
|
||||
- 8.8.8.8
|
||||
mariadb:
|
||||
image: mariadb:10.6
|
||||
environment:
|
||||
- MYSQL_ROOT_PASSWORD=nette
|
||||
- MYSQL_DATABASE=survey
|
||||
- MYSQL_USER=nette
|
||||
- MYSQL_PASSWORD=nette
|
||||
healthcheck:
|
||||
test: [ "CMD", "healthcheck.sh", "--connect", "--innodb_initialized" ]
|
||||
start_period: 10s
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 10
|
||||
|
||||
redis:
|
||||
image: redis:7.4.2
|
||||
|
20
docker/nginx/default.conf
Normal file
20
docker/nginx/default.conf
Normal file
@ -0,0 +1,20 @@
|
||||
server {
|
||||
index index.php index.html;
|
||||
server_name symfony_example_app;
|
||||
error_log stderr debug;
|
||||
access_log stderr;
|
||||
listen 80;
|
||||
root /var/www/html/www;
|
||||
|
||||
|
||||
|
||||
location / {
|
||||
try_files $uri /index.php$is_args$args;
|
||||
}
|
||||
|
||||
location ~ ^/.+\.php(/|$) {
|
||||
fastcgi_pass php-fpm:9000;
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
}
|
||||
}
|
21
migrations/001_init.sql
Normal file
21
migrations/001_init.sql
Normal file
@ -0,0 +1,21 @@
|
||||
|
||||
CREATE TABLE survey (
|
||||
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||
name VARCHAR(255) NOT NULL COLLATE utf8_czech_ci,
|
||||
comments TEXT COLLATE utf8_czech_ci,
|
||||
agreement BOOLEAN NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_survey_name ON survey (name);
|
||||
CREATE FULLTEXT INDEX idx_survey_comments ON survey (comments);
|
||||
|
||||
CREATE TABLE interests
|
||||
(
|
||||
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||
survey_id BIGINT UNSIGNED NOT NULL,
|
||||
interest VARCHAR(255) NOT NULL COLLATE utf8_czech_ci ,
|
||||
FOREIGN KEY (survey_id) REFERENCES survey (id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_interests_survey_id ON interests (survey_id);
|
||||
CREATE INDEX idx_interests_interest ON interests (interest);
|
30
phpcs.xml.dist
Normal file
30
phpcs.xml.dist
Normal file
@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<ruleset xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/squizlabs/php_codesniffer/phpcs.xsd">
|
||||
|
||||
<arg name="basepath" value="."/>
|
||||
<arg name="cache" value=".phpcs-cache"/>
|
||||
<arg name="colors"/>
|
||||
<arg name="extensions" value="php"/>
|
||||
|
||||
<config name="installed_paths" value="vendor/slevomat/coding-standard"/>
|
||||
|
||||
<rule ref="vendor/slevomat/coding-standard/SlevomatCodingStandard/Sniffs/TypeHints/DeclareStrictTypesSniff.php">
|
||||
<properties>
|
||||
<property name="spacesCountAroundEqualsSign">0</property>
|
||||
</properties>
|
||||
</rule>
|
||||
|
||||
<rule ref="PSR12"/>
|
||||
<rule ref="Generic.Files.LineLength">
|
||||
<properties>
|
||||
<property name="lineLimit" value="N"/>
|
||||
<property name="absoluteLineLimit" value="M"/>
|
||||
</properties>
|
||||
</rule>
|
||||
|
||||
<file>app/</file>
|
||||
<file>tests/</file>
|
||||
|
||||
</ruleset>
|
5
phpstan.neon.dist
Normal file
5
phpstan.neon.dist
Normal file
@ -0,0 +1,5 @@
|
||||
parameters:
|
||||
level: 8
|
||||
paths:
|
||||
- app/
|
||||
- tests/
|
19
phpunit.xml
Normal file
19
phpunit.xml
Normal file
@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
colors="true"
|
||||
>
|
||||
<testsuites>
|
||||
<testsuite name="Unit">
|
||||
<directory>tests/Unit</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
<directory>app</directory>
|
||||
</include>
|
||||
</source>
|
||||
<php>
|
||||
</php>
|
||||
</phpunit>
|
64
tests/Unit/UI/DTO/FilterDTOTest.php
Normal file
64
tests/Unit/UI/DTO/FilterDTOTest.php
Normal file
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\UI\DTO\FilterDTO;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class FilterDTOTest extends TestCase
|
||||
{
|
||||
public function testCreateFromInvalidDataWithAcceptedFilters(): void
|
||||
{
|
||||
$data = [
|
||||
'name' => 'John',
|
||||
'age' => 30,
|
||||
'invalid_key' => 'should not be included',
|
||||
];
|
||||
|
||||
$acceptFilters = ['name', 'age'];
|
||||
|
||||
$filterDTO = FilterDTO::createFromData($data, $acceptFilters);
|
||||
|
||||
$this->assertInstanceOf(FilterDTO::class, $filterDTO);
|
||||
$this->assertSame(
|
||||
[
|
||||
'name' => 'John',
|
||||
'age' => 30,
|
||||
],
|
||||
$filterDTO->filters
|
||||
);
|
||||
}
|
||||
|
||||
public function testCreateFromInvalidDataWithNoAcceptedFilters(): void
|
||||
{
|
||||
$data = [
|
||||
'name' => 'John',
|
||||
'age' => 30,
|
||||
'invalid_key' => 'should not be included',
|
||||
];
|
||||
|
||||
$acceptFilters = [];
|
||||
|
||||
$filterDTO = FilterDTO::createFromData($data, $acceptFilters);
|
||||
|
||||
$this->assertInstanceOf(FilterDTO::class, $filterDTO);
|
||||
$this->assertSame([], $filterDTO->filters);
|
||||
}
|
||||
|
||||
public function testCreateFromInvalidDataWithoutMatchingKeys(): void
|
||||
{
|
||||
$data = [
|
||||
'invalid_key1' => 'value1',
|
||||
'invalid_key2' => 'value2',
|
||||
];
|
||||
|
||||
$acceptFilters = ['name', 'age'];
|
||||
|
||||
$filterDTO = FilterDTO::createFromData($data, $acceptFilters);
|
||||
|
||||
$this->assertInstanceOf(FilterDTO::class, $filterDTO);
|
||||
$this->assertSame([], $filterDTO->filters);
|
||||
}
|
||||
}
|
58
tests/Unit/UI/DTO/OrderDTOTest.php
Normal file
58
tests/Unit/UI/DTO/OrderDTOTest.php
Normal file
@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\UI\DTO\OrderDirection;
|
||||
use App\UI\DTO\OrderDTO;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class OrderDTOTest extends TestCase
|
||||
{
|
||||
public function testCreateOrReturnNullFromInvalidDataWithValidInputs(): void
|
||||
{
|
||||
$acceptFilters = ['name', 'created_at'];
|
||||
$order = 'name';
|
||||
$direction = 'ASC';
|
||||
|
||||
$orderDTO = OrderDTO::createOrReturnNullFromData($order, $direction, $acceptFilters);
|
||||
|
||||
$this->assertInstanceOf(OrderDTO::class, $orderDTO);
|
||||
$this->assertSame($order, $orderDTO->column);
|
||||
$this->assertSame(OrderDirection::ASC, $orderDTO->direction);
|
||||
}
|
||||
|
||||
public function testCreateOrReturnNullFromInvalidDataWithInvalidOrder(): void
|
||||
{
|
||||
$acceptFilters = ['name', 'created_at'];
|
||||
$order = 'invalid_column';
|
||||
$direction = 'ASC';
|
||||
|
||||
$orderDTO = OrderDTO::createOrReturnNullFromData($order, $direction, $acceptFilters);
|
||||
|
||||
$this->assertNull($orderDTO);
|
||||
}
|
||||
|
||||
public function testCreateOrReturnNullFromInvalidDataWithInvalidDirection(): void
|
||||
{
|
||||
$acceptFilters = ['name', 'created_at'];
|
||||
$order = 'name';
|
||||
$direction = 'INVALID_DIRECTION';
|
||||
|
||||
$orderDTO = OrderDTO::createOrReturnNullFromData($order, $direction, $acceptFilters);
|
||||
|
||||
$this->assertNull($orderDTO);
|
||||
}
|
||||
|
||||
public function testCreateOrReturnNullFromInvalidDataWithEmptyFilters(): void
|
||||
{
|
||||
$acceptFilters = [];
|
||||
$order = 'name';
|
||||
$direction = 'ASC';
|
||||
|
||||
$orderDTO = OrderDTO::createOrReturnNullFromData($order, $direction, $acceptFilters);
|
||||
|
||||
$this->assertNull($orderDTO);
|
||||
}
|
||||
}
|
41
www/.htaccess
Normal file
41
www/.htaccess
Normal file
@ -0,0 +1,41 @@
|
||||
# Apache configuration file (see https://httpd.apache.org/docs/current/mod/quickreference.html)
|
||||
|
||||
# Allow access to all resources by default
|
||||
Require all granted
|
||||
|
||||
# Disable directory listing for security reasons
|
||||
<IfModule mod_autoindex.c>
|
||||
Options -Indexes
|
||||
</IfModule>
|
||||
|
||||
# Enable pretty URLs (removing the need for "index.php" in the URL)
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
|
||||
# Uncomment the next line if you want to set the base URL for rewrites
|
||||
# RewriteBase /
|
||||
|
||||
# Force usage of HTTPS (secure connection). Uncomment if you have SSL setup.
|
||||
# RewriteCond %{HTTPS} !on
|
||||
# RewriteRule .? https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
|
||||
|
||||
# Permit requests to the '.well-known' directory (used for SSL verification and more)
|
||||
RewriteRule ^\.well-known/.* - [L]
|
||||
|
||||
# Block access to hidden files (starting with a dot) and URLs resembling WordPress admin paths
|
||||
RewriteRule /\.|^\.|^wp-(login|admin|includes|content) - [F]
|
||||
|
||||
# Return 404 for missing files with specific extensions (images, scripts, styles, archives)
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteRule \.(pdf|js|mjs|ico|gif|jpg|jpeg|png|webp|avif|svg|css|rar|zip|7z|tar\.gz|map|eot|ttf|otf|woff|woff2)$ - [L]
|
||||
|
||||
# Front controller pattern - all requests are routed through index.php
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule . index.php [L]
|
||||
</IfModule>
|
||||
|
||||
# Enable gzip compression for text files
|
||||
<IfModule mod_deflate.c>
|
||||
AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css application/javascript application/json application/xml application/rss+xml image/svg+xml
|
||||
</IfModule>
|
BIN
www/favicon.ico
Normal file
BIN
www/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.5 KiB |
11
www/index.php
Normal file
11
www/index.php
Normal file
@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
$bootstrap = new App\Bootstrap;
|
||||
$container = $bootstrap->bootWebApplication();
|
||||
|
||||
$application = $container->getByType(Nette\Application\Application::class);
|
||||
$application->run();
|
0
www/robots.txt
Normal file
0
www/robots.txt
Normal file
Loading…
x
Reference in New Issue
Block a user