taktik-nette
This commit is contained in:
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}
|
||||
Reference in New Issue
Block a user