Compare commits

..

4 commits

Author SHA1 Message Date
4e1ea20fe7
remove useless console.log 2025-05-24 23:14:13 +02:00
ab9f1bfeec
implement darkmode 2025-05-24 23:11:42 +02:00
b8e9a02a14
style stylee style 2025-05-24 23:03:21 +02:00
f38c05d97c
add tailiwindi 2025-05-24 23:02:31 +02:00
43 changed files with 3038 additions and 1443 deletions

View file

@ -1,4 +0,0 @@
###> symfony/framework-bundle ###
APP_SECRET=11c8937d48993fb3aee1a476413161f5
###< symfony/framework-bundle ###

View file

@ -3,7 +3,7 @@ jobs:
ls:
runs-on: docker
container:
image: git.php.fail/lubiana/container/php:8.4.8-ci
image: git.php.fail/lubiana/container/php:8.4.3-ci
steps:
- name: Manually checkout
env:

View file

@ -6,7 +6,7 @@ jobs:
ls:
runs-on: docker
container:
image: git.php.fail/lubiana/container/php:8.4.8-ci
image: git.php.fail/lubiana/container/php:8.4.3-ci
steps:
- name: Manually checkout
env:

View file

@ -4,7 +4,7 @@ jobs:
ls:
runs-on: docker
container:
image: git.php.fail/lubiana/container/php:8.4.8-ci
image: git.php.fail/lubiana/container/php:8.4.3-ci
steps:
- name: Manually checkout
env:

12
.gitignore vendored
View file

@ -18,11 +18,7 @@
.DS_Store
###> squizlabs/php_codesniffer ###
/.phpcs-cache
/phpcs.xml
###< squizlabs/php_codesniffer ###
###> phpstan/phpstan ###
phpstan.neon
###< phpstan/phpstan ###
###> symfony/asset-mapper ###
/public/assets/
/assets/vendor/
###< symfony/asset-mapper ###

156
.junie/guidelines.md Normal file
View file

@ -0,0 +1,156 @@
# Development Guidelines
This document provides guidelines and instructions for developing and maintaining the Futtern project.
## Build/Configuration Instructions
### Environment Setup
1. **PHP Requirements**: The project requires PHP 8.4 or higher with the following extensions:
- ctype
- iconv
2. **Composer**: Install dependencies using Composer:
```bash
composer install
```
3. **Environment Configuration**: Copy `.env` to `.env.local` and adjust the settings as needed for your local environment.
### Development Server
Start the Symfony development server:
```bash
symfony server:start
```
### Building Assets
The project uses Tailwind CSS via the symfonycasts/tailwind-bundle:
```bash
# Install importmap assets
symfony console importmap:install
```
## Deployment
The project includes several deployment scripts:
1. **Prepare for Deployment**:
```bash
./deploy/prepare-deploy.sh
```
This script creates a clean copy of the application with only production dependencies.
2. **Local Deployment**:
```bash
./deploy/local-deploy.sh
```
This script deploys the application to a remote server, backing up the database and restarting the service.
3. **Update After Deployment**:
```bash
./deploy/update.sh
```
This script is run on the remote server to clear cache, warm up cache, and run database migrations.
## Testing Information
### Testing Framework
The project uses Pest PHP, a testing framework built on top of PHPUnit, for testing. Tests are organized into:
- **Feature Tests**: For testing controllers and API endpoints
- **Unit Tests**: For testing individual components
### Running Tests
Run all tests:
```bash
composer test
# or
./vendor/bin/pest
```
Run specific tests:
```bash
./vendor/bin/pest tests/Unit/ExampleTest.php
```
Run tests in parallel:
```bash
./vendor/bin/pest --parallel
```
### Creating Tests
1. **Unit Tests**: Create files in the `tests/Unit` directory.
2. **Feature Tests**:
- Controller tests: Create files in `tests/Feature/Controller`
- API tests: Create files in `tests/Feature/Api`
### Example Test
Here's a simple example of a Pest PHP test:
```php
<?php declare(strict_types=1);
test('example test', function (): void {
expect(true)->toBeTrue();
expect(1 + 1)->toBe(2);
expect('hello world')->toContain('world');
});
test('array operations', function (): void {
$array = [1, 2, 3];
expect($array)->toHaveCount(3);
expect($array)->toContain(2);
expect($array[0])->toBe(1);
});
```
### Test Base Classes
- `DbWebTest`: Base class for controller tests
- `DbApiTestCase`: Base class for API tests
## Code Quality and Style
### Code Style
The project uses Easy Coding Standard (ECS) for code style checking:
```bash
composer lint
# or
./vendor/bin/ecs --fix
```
The code style is defined in `ecs.php` and includes:
- Custom rules from `Lubiana\CodeQuality\LubiSetList::ECS`
- All classes should be declared as final
### Code Refactoring
The project uses Rector for automated code refactoring:
```bash
./vendor/bin/rector
```
The refactoring rules are defined in `rector.php` and include:
- Custom rules from `Lubiana\CodeQuality\LubiSetList::RECTOR`
- StaticClosureRector is skipped in the tests directory
### Development Workflow
1. Make changes to the code
2. Run tests to ensure functionality: `composer test`
3. Fix code style issues: `composer lint`
4. Commit and push changes
5. Deploy using the deployment scripts
## Debugging
- Use the Symfony Web Profiler in development mode
- Check logs in `var/log/`
- For API debugging, use the API Platform documentation at `/api`

3
.symfony.local.yaml Normal file
View file

@ -0,0 +1,3 @@
workers:
tailwind:
cmd: ['symfony', 'console', 'tailwind:build', '--watch']

8
assets/app.js Normal file
View file

@ -0,0 +1,8 @@
/*
* Welcome to your app's main JavaScript file!
*
* This file will be included onto the page via the importmap() Twig function,
* which should already be in your base.html.twig.
*/
import './styles/app.css';

7
assets/styles/app.css Normal file
View file

@ -0,0 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
background-color: skyblue;
}

View file

@ -7,46 +7,47 @@
"php": ">=8.4",
"ext-ctype": "*",
"ext-iconv": "*",
"api-platform/doctrine-orm": "*",
"api-platform/symfony": "*",
"doctrine/dbal": "^4.2.3",
"doctrine/doctrine-bundle": "^2.14.1",
"doctrine/doctrine-migrations-bundle": "^3.4.2",
"doctrine/orm": "^3.4.0",
"api-platform/doctrine-orm": "^4.0.0",
"api-platform/symfony": "4.1.4",
"doctrine/dbal": "^4.1",
"doctrine/doctrine-bundle": "^2.12",
"doctrine/doctrine-migrations-bundle": "^3.3.1",
"doctrine/orm": "^3.2.1",
"nelmio/cors-bundle": "^2.5",
"phpdocumentor/reflection-docblock": "^5.6.2",
"phpdocumentor/reflection-docblock": "^5.6",
"phpstan/phpdoc-parser": "^1.33",
"psr/clock": "^1.0",
"symfony/asset": "7.3.*",
"symfony/console": "7.3.*",
"symfony/dotenv": "7.3.*",
"symfony/expression-language": "7.3.*",
"symfony/flex": "^2.7.1",
"symfony/form": "7.3.*",
"symfony/framework-bundle": "7.3.*",
"symfony/property-access": "7.3.*",
"symfony/property-info": "7.3.*",
"symfony/runtime": "7.3.*",
"symfony/security-bundle": "7.3.*",
"symfony/security-csrf": "7.3.*",
"symfony/serializer": "7.3.*",
"symfony/twig-bundle": "7.3.*",
"symfony/uid": "7.3.*",
"symfony/validator": "7.3.*",
"symfony/yaml": "7.3.*"
"symfony/asset": "7.2.*",
"symfony/console": "7.1.*",
"symfony/dotenv": "7.1.*",
"symfony/expression-language": "7.2.*",
"symfony/flex": "^2.4.6",
"symfony/form": "7.1.*",
"symfony/framework-bundle": "7.1.*",
"symfony/property-access": "7.2.*",
"symfony/property-info": "7.2.*",
"symfony/runtime": "7.1.*",
"symfony/security-bundle": "7.2.*",
"symfony/security-csrf": "7.1.*",
"symfony/serializer": "7.2.*",
"symfony/twig-bundle": "7.1.*",
"symfony/uid": "7.1.*",
"symfony/validator": "7.1.*",
"symfony/yaml": "7.1.*",
"symfonycasts/tailwind-bundle": "^0.10.0"
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^4.1",
"liip/test-fixtures-bundle": "^3.4",
"doctrine/doctrine-fixtures-bundle": "^4.0",
"liip/test-fixtures-bundle": "^3.2",
"lubiana/code-quality": "^1.7.2",
"pestphp/pest": "^3.8.2",
"symfony/browser-kit": "7.3.*",
"symfony/css-selector": "7.3.*",
"symfony/http-client": "7.3.*",
"symfony/maker-bundle": "^1.63",
"symfony/stopwatch": "7.3.*",
"symfony/web-profiler-bundle": "7.3.*",
"symplify/config-transformer": "^12.4.0"
"pestphp/pest": "^3.6",
"symfony/browser-kit": "7.2.*",
"symfony/css-selector": "7.2.*",
"symfony/http-client": "7.2.*",
"symfony/maker-bundle": "^1.60",
"symfony/stopwatch": "7.2.*",
"symfony/web-profiler-bundle": "7.2.*",
"symplify/config-transformer": "^12.3.4"
},
"config": {
"allow-plugins": {
@ -80,13 +81,13 @@
"symfony/polyfill-php80": "*",
"symfony/polyfill-php81": "*",
"symfony/polyfill-php82": "*",
"symfony/polyfill-php83": "*",
"symfony/polyfill-php84": "*"
"symfony/polyfill-php83": "*"
},
"scripts": {
"auto-scripts": {
"cache:clear": "symfony-cmd",
"assets:install %PUBLIC_DIR%": "symfony-cmd"
"assets:install %PUBLIC_DIR%": "symfony-cmd",
"importmap:install": "symfony-cmd"
},
"post-install-cmd": [
"@auto-scripts"
@ -107,7 +108,7 @@
"extra": {
"symfony": {
"allow-contrib": false,
"require": "7.3.*"
"require": "7.2.*"
}
}
}

3170
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -11,6 +11,7 @@ use Symfony\Bundle\MakerBundle\MakerBundle;
use Symfony\Bundle\SecurityBundle\SecurityBundle;
use Symfony\Bundle\TwigBundle\TwigBundle;
use Symfony\Bundle\WebProfilerBundle\WebProfilerBundle;
use Symfonycasts\TailwindBundle\SymfonycastsTailwindBundle;
return [
FrameworkBundle::class => [
@ -42,11 +43,14 @@ return [
NelmioCorsBundle::class => [
'all' => true,
],
ApiPlatformBundle::class => [
'all' => true,
],
LiipTestFixturesBundle::class => [
'dev' => true,
'test' => true,
],
ApiPlatformBundle::class => [
SymfonycastsTailwindBundle::class => [
'all' => true,
],
];

View file

@ -4,8 +4,10 @@ use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigura
return static function (ContainerConfigurator $containerConfigurator): void {
$containerConfigurator->extension('api_platform', [
'title' => 'Hello API Platform',
'title' => 'Futtern API',
'version' => '1.0.0',
'show_webby' => false,
'enable_swagger' => true,
'defaults' => [
'stateless' => true,
'cache_headers' => [

View file

@ -0,0 +1,21 @@
<?php declare(strict_types=1);
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return static function (ContainerConfigurator $containerConfigurator): void {
$containerConfigurator->extension('framework', [
'asset_mapper' => [
'paths' => [
'assets/',
],
'missing_import_mode' => 'strict',
],
]);
if ($containerConfigurator->env() === 'prod') {
$containerConfigurator->extension('framework', [
'asset_mapper' => [
'missing_import_mode' => 'warn',
],
]);
}
};

View file

@ -1,20 +0,0 @@
<?php declare(strict_types=1);
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return static function (ContainerConfigurator $containerConfigurator): void {
$containerConfigurator->extension('framework', [
'form' => [
'csrf_protection' => [
'token_id' => 'submit',
],
],
'csrf_protection' => [
'stateless_token_ids' => [
'submit',
'authenticate',
'logout',
],
],
]);
};

View file

@ -3,9 +3,7 @@
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return static function (ContainerConfigurator $containerConfigurator): void {
$containerConfigurator->extension('framework', [
'property_info' => [
'with_constructor_extractor' => true,
],
$containerConfigurator->extension('symfonycasts_tailwind', [
'binary_version' => 'v3.4.17',
]);
};

19
importmap.php Normal file
View file

@ -0,0 +1,19 @@
<?php declare(strict_types=1);
/**
* Returns the importmap for this application.
*
* - "path" is a path inside the asset mapper system. Use the
* "debug:asset-map" command to see the full list of paths.
*
* - "entrypoint" (JavaScript only) set to true for any module that will
* be used as an "entrypoint" (and passed to the importmap() Twig function).
*
* The "importmap:require" command can be used to add new entries to this file.
*/
return [
'app' => [
'path' => './assets/app.js',
'entrypoint' => true,
],
];

20
php-styler.php Normal file
View file

@ -0,0 +1,20 @@
<?php declare(strict_types=1);
use PhpStyler\Config;
use PhpStyler\Files;
use PhpStyler\Styler;
return new Config(
styler: new Styler(lineLen: 79),
files: new Files(
__DIR__ . '/bin',
__DIR__ . '/public',
__DIR__ . '/src',
__DIR__ . '/config',
__DIR__ . '/tests',
__DIR__ . '/php-styler.php',
__DIR__ . '/ecs.php',
__DIR__ . '/rector.php',
),
cache: __DIR__ . '/.php-styler.cache',
);

View file

@ -3,14 +3,9 @@
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Repository\FoodOrderRepository;
use App\State\OpenOrdersProvider;
use App\State\LatestOrderProvider;
use App\State\OpenFoodOrderProvider;
use DateInterval;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
@ -21,26 +16,12 @@ use Symfony\Component\Uid\Ulid;
use function iterator_to_array;
#[ApiResource(
operations: [
new GetCollection(
uriTemplate: 'food_orders/open',
description: 'Get only open orders',
provider: OpenOrdersProvider::class,
),
new GetCollection(
uriTemplate: 'food_orders/latest',
description: 'Get the latest created order',
provider: LatestOrderProvider::class,
),
new GetCollection,
new Get,
new Post,
new Put,
new Delete,
]
)]
#[ORM\Entity(repositoryClass: FoodOrderRepository::class)]
#[GetCollection(
uriTemplate: '/food_orders/open',
provider: OpenFoodOrderProvider::class,
)]
#[ApiResource]
class FoodOrder
{
#[ORM\Column(nullable: true)]

View file

@ -12,8 +12,8 @@ use Symfony\Bridge\Doctrine\IdGenerator\UlidGenerator;
use Symfony\Bridge\Doctrine\Types\UlidType;
use Symfony\Component\Uid\Ulid;
#[ApiResource]
#[ORM\Entity(repositoryClass: MenuItemRepository::class)]
#[ApiResource]
class MenuItem
{
#[ORM\Column(length: 255)]

View file

@ -9,8 +9,8 @@ use Symfony\Bridge\Doctrine\IdGenerator\UlidGenerator;
use Symfony\Bridge\Doctrine\Types\UlidType;
use Symfony\Component\Uid\Ulid;
#[ApiResource]
#[ORM\Entity(repositoryClass: OrderItemRepository::class)]
#[ApiResource]
class OrderItem
{
#[ORM\Column(length: 255)]

View file

@ -62,16 +62,4 @@ final class FoodOrderRepository extends ServiceEntityRepository
->getQuery()
->getResult();
}
/**
* @return FoodOrder|null
*/
public function findLatestOrder(): ?FoodOrder
{
return $this->createQueryBuilder('alias')
->orderBy('alias.id', 'DESC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
}
}

View file

@ -1,22 +0,0 @@
<?php declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\FoodOrder;
use App\Repository\FoodOrderRepository;
use Override;
final readonly class LatestOrderProvider implements ProviderInterface
{
public function __construct(
private FoodOrderRepository $repository
) {}
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?FoodOrder
{
return $this->repository->findLatestOrder();
}
}

View file

@ -4,21 +4,17 @@ namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\FoodOrder;
use App\Repository\FoodOrderRepository;
use Override;
final readonly class OpenOrdersProvider implements ProviderInterface
final readonly class OpenFoodOrderProvider implements ProviderInterface
{
public function __construct(
private FoodOrderRepository $repository
) {}
/**
* @return FoodOrder[]
*/
#[Override]
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
{
return $this->repository->findOpenOrders();
}

View file

@ -1,11 +1,11 @@
{
"api-platform/core": {
"version": "4.1",
"api-platform/symfony": {
"version": "4.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "4.0",
"ref": "cb9e6b8ceb9b62f32d41fc8ad72a25d5bd674c6d"
"ref": "e9952e9f393c2d048f10a78f272cd35e807d972b"
},
"files": [
"config/packages/api_platform.yaml",
@ -13,22 +13,13 @@
"src/ApiResource/.gitignore"
]
},
"doctrine/deprecations": {
"version": "1.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "1.0",
"ref": "87424683adc81d7dc305eefec1fced883084aab9"
}
},
"doctrine/doctrine-bundle": {
"version": "2.14",
"version": "2.12",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.13",
"ref": "620b57f496f2e599a6015a9fa222c2ee0a32adcb"
"version": "2.12",
"ref": "7266981c201efbbe02ae53c87f8bb378e3f825ae"
},
"files": [
"config/packages/doctrine.yaml",
@ -37,7 +28,7 @@
]
},
"doctrine/doctrine-fixtures-bundle": {
"version": "4.1",
"version": "4.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
@ -49,7 +40,7 @@
]
},
"doctrine/doctrine-migrations-bundle": {
"version": "3.4",
"version": "3.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
@ -62,7 +53,7 @@
]
},
"liip/test-fixtures-bundle": {
"version": "3.4.0"
"version": "3.2.1"
},
"nelmio/cors-bundle": {
"version": "2.5",
@ -77,46 +68,54 @@
]
},
"phpstan/phpstan": {
"version": "1.12",
"version": "1.11",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "1.0",
"ref": "5e490cc197fb6bb1ae22e5abbc531ddc633b6767"
},
"files": [
"phpstan.dist.neon"
]
}
},
"phpunit/phpunit": {
"version": "11.5",
"version": "11.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "11.1",
"ref": "c6658a60fc9d594805370eacdf542c3d6b5c0869"
"version": "9.6",
"ref": "7364a21d87e658eb363c5020c072ecfdc12e2326"
},
"files": [
".env.test",
"phpunit.xml.dist",
"tests/bootstrap.php",
"bin/phpunit"
"tests/bootstrap.php"
]
},
"squizlabs/php_codesniffer": {
"version": "3.13",
"version": "3.10",
"recipe": {
"repo": "github.com/symfony/recipes-contrib",
"branch": "main",
"version": "3.6",
"ref": "1019e5c08d4821cb9b77f4891f8e9c31ff20ac6f"
}
},
"symfony/asset-mapper": {
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.4",
"ref": "5ad1308aa756d58f999ffbe1540d1189f5d7d14a"
},
"files": [
"phpcs.xml.dist"
"assets/app.js",
"assets/styles/app.css",
"config/packages/asset_mapper.yaml",
"importmap.php"
]
},
"symfony/console": {
"version": "7.3",
"version": "7.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
@ -128,37 +127,24 @@
]
},
"symfony/flex": {
"version": "2.7",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "2.4",
"ref": "52e9754527a15e2b79d9a610f98185a1fe46622a"
},
"files": [
".env",
".env.dev"
]
},
"symfony/form": {
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.2",
"ref": "7d86a6723f4a623f59e2bf966b6aad2fc461d36b"
"version": "1.0",
"ref": "146251ae39e06a95be0fe3d13c807bcf3938b172"
},
"files": [
"config/packages/csrf.yaml"
".env"
]
},
"symfony/framework-bundle": {
"version": "7.3",
"version": "7.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.3",
"ref": "5a1497d539f691b96afd45ae397ce5fe30beb4b9"
"version": "7.0",
"ref": "6356c19b9ae08e7763e4ba2d9ae63043efc75db5"
},
"files": [
"config/packages/cache.yaml",
@ -168,12 +154,11 @@
"config/services.yaml",
"public/index.php",
"src/Controller/.gitignore",
"src/Kernel.php",
".editorconfig"
"src/Kernel.php"
]
},
"symfony/maker-bundle": {
"version": "1.63",
"version": "1.60",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
@ -181,20 +166,8 @@
"ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f"
}
},
"symfony/property-info": {
"version": "7.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.3",
"ref": "dae70df71978ae9226ae915ffd5fad817f5ca1f7"
},
"files": [
"config/packages/property_info.yaml"
]
},
"symfony/routing": {
"version": "7.3",
"version": "7.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
@ -207,7 +180,7 @@
]
},
"symfony/security-bundle": {
"version": "7.3",
"version": "7.2",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
@ -220,7 +193,7 @@
]
},
"symfony/twig-bundle": {
"version": "7.3",
"version": "7.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
@ -233,7 +206,7 @@
]
},
"symfony/uid": {
"version": "7.3",
"version": "7.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
@ -242,7 +215,7 @@
}
},
"symfony/validator": {
"version": "7.3",
"version": "7.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
@ -254,16 +227,28 @@
]
},
"symfony/web-profiler-bundle": {
"version": "7.3",
"version": "7.1",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "7.3",
"ref": "5b2b543e13942495c0003f67780cb4448af9e606"
"version": "6.1",
"ref": "e42b3f0177df239add25373083a564e5ead4e13a"
},
"files": [
"config/packages/web_profiler.yaml",
"config/routes/web_profiler.yaml"
]
},
"symfonycasts/tailwind-bundle": {
"version": "0.10",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "0.8",
"ref": "4ea7c9488fdce8943520daf3fdc31e93e5b59c64"
},
"files": [
"config/packages/symfonycasts_tailwind.yaml"
]
}
}

12
tailwind.config.js Normal file
View file

@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./assets/**/*.js",
"./templates/**/*.html.twig",
],
darkMode: 'media', // Use 'media' to respect the user's system preference
theme: {
extend: {},
},
plugins: [],
}

View file

@ -1,4 +1,24 @@
{{ form_start(form) }}
{{ form_widget(form) }}
<button class="btn">{{ button_label|default('Save') }}</button>
{{ form_start(form, {'attr': {'class': 'space-y-6'}}) }}
{% for field in form %}
{% if field.vars.name != '_token' %}
<div class="space-y-2">
{{ form_label(field, null, {'label_attr': {'class': 'block text-sm font-medium leading-6 text-gray-900 dark:text-gray-100'}}) }}
<div>
{{ form_widget(field, {'attr': {'class': 'block w-full rounded-md border-0 py-1.5 text-gray-900 dark:text-gray-100 bg-white dark:bg-gray-700 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:ring-2 focus:ring-inset focus:ring-indigo-600 dark:focus:ring-indigo-500 sm:text-sm sm:leading-6'}}) }}
</div>
{% if field.vars.errors|length > 0 %}
<div class="mt-1 text-sm text-red-600 dark:text-red-400">
{{ form_errors(field) }}
</div>
{% endif %}
{% if field.vars.help is defined and field.vars.help %}
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ field.vars.help }}</p>
{% endif %}
</div>
{% endif %}
{% endfor %}
<div class="mt-6 flex items-center justify-end gap-x-6">
<a href="{{ app.request.headers.get('referer', '/') }}" class="text-sm font-semibold leading-6 text-gray-900 dark:text-gray-100">Cancel</a>
<button type="submit" class="rounded-md bg-indigo-600 dark:bg-indigo-700 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 dark:hover:bg-indigo-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500">{{ button_label|default('Save') }}</button>
</div>
{{ form_end(form) }}

View file

@ -1,46 +1,93 @@
<!DOCTYPE html>
<html>
<html class="h-full bg-gray-100 dark:bg-gray-900">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="dark light">
<meta name="theme-color" content="#0000ff" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#222222" media="(prefers-color-scheme: dark)">
<title>{% block title %}Welcome!{% endblock %}</title>
<link rel="icon" type="image/svg+xml"
href="{{ favicon }}" />
{% set currentDate = "now"|date("d") %}
{% if currentDate % 4 == 0 %}
<link rel="stylesheet" href="/static/css/new.min.css">
{% elseif currentDate % 4 == 1 %}
<link rel="stylesheet" href="/static/css/simple.min.css">
{% elseif currentDate % 4 == 2 %}
<link rel="stylesheet" href="/static/css/water.min.css">
{% else %}
<link rel="stylesheet" href="/static/css/fieber.css">
{% endif %}
<style>
label{
display: block;
}
</style>
<title>{% block title %}Welcome!{% endblock %} - Futtern</title>
<link rel="icon" type="image/svg+xml" href="{{ favicon }}" />
<script src="/static/js/htmx.min.js"></script>
{% block stylesheets %}
<link rel="stylesheet" href="{{ asset('styles/app.css') }}">
{% endblock %}
{% block javascripts %}
{{ importmap() }}
{% endblock %}
</head>
<body>
<header>
<p>Hello {{ app.request.cookies.get('username', 'nobody') }} - <a href="{{ path('username') }}">change name</a></p>
<nav>
<a href="{{ path('app_food_order_index') }}">Orders</a> /
<a href="{{ path('app_food_vendor_index') }}">Vendors</a> /
<a
href="https://git.hannover.ccc.de/lubiana/futtern/issues/new"
target="_blank"
>Create Issue</a> /
<a href="/api">API</a>
<body class="h-full dark:text-gray-100">
<div class="min-h-full">
<nav class="bg-indigo-600 dark:bg-indigo-800">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 items-center justify-between">
<div class="flex items-center">
<div class="flex-shrink-0">
<span class="text-white text-xl font-bold">Futtern</span>
</div>
<div class="hidden md:block">
<div class="ml-10 flex items-baseline space-x-4">
<a href="{{ path('app_food_order_index') }}" class="text-white hover:bg-indigo-500 hover:bg-opacity-75 dark:hover:bg-indigo-700 rounded-md px-3 py-2 text-sm font-medium">Orders</a>
<a href="{{ path('app_food_vendor_index') }}" class="text-white hover:bg-indigo-500 hover:bg-opacity-75 dark:hover:bg-indigo-700 rounded-md px-3 py-2 text-sm font-medium">Vendors</a>
<a href="https://git.hannover.ccc.de/lubiana/futtern/issues/new" target="_blank" class="text-white hover:bg-indigo-500 hover:bg-opacity-75 dark:hover:bg-indigo-700 rounded-md px-3 py-2 text-sm font-medium">Create Issue</a>
<a href="/api" class="text-white hover:bg-indigo-500 hover:bg-opacity-75 dark:hover:bg-indigo-700 rounded-md px-3 py-2 text-sm font-medium">API</a>
</div>
</div>
</div>
<div class="hidden md:block">
<div class="ml-4 flex items-center md:ml-6">
<div class="text-white">
Hello {{ app.request.cookies.get('username', 'nobody') }} -
<a href="{{ path('username') }}" class="text-indigo-200 hover:text-white">change name</a>
</div>
</div>
</div>
<div class="-mr-2 flex md:hidden">
<!-- Mobile menu button -->
<button type="button" class="relative inline-flex items-center justify-center rounded-md bg-indigo-600 dark:bg-indigo-800 p-2 text-indigo-200 hover:bg-indigo-500 dark:hover:bg-indigo-700 hover:bg-opacity-75 hover:text-white focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-indigo-600 dark:focus:ring-offset-indigo-800" aria-controls="mobile-menu" aria-expanded="false">
<span class="absolute -inset-0.5"></span>
<span class="sr-only">Open main menu</span>
<!-- Menu open: "hidden", Menu closed: "block" -->
<svg class="block h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
</button>
</div>
</div>
</div>
<!-- Mobile menu, show/hide based on menu state. -->
<div class="md:hidden" id="mobile-menu">
<div class="space-y-1 px-2 pb-3 pt-2 sm:px-3">
<a href="{{ path('app_food_order_index') }}" class="text-white hover:bg-indigo-500 dark:hover:bg-indigo-700 hover:bg-opacity-75 block rounded-md px-3 py-2 text-base font-medium">Orders</a>
<a href="{{ path('app_food_vendor_index') }}" class="text-white hover:bg-indigo-500 dark:hover:bg-indigo-700 hover:bg-opacity-75 block rounded-md px-3 py-2 text-base font-medium">Vendors</a>
<a href="https://git.hannover.ccc.de/lubiana/futtern/issues/new" target="_blank" class="text-white hover:bg-indigo-500 dark:hover:bg-indigo-700 hover:bg-opacity-75 block rounded-md px-3 py-2 text-base font-medium">Create Issue</a>
<a href="/api" class="text-white hover:bg-indigo-500 dark:hover:bg-indigo-700 hover:bg-opacity-75 block rounded-md px-3 py-2 text-base font-medium">API</a>
</div>
<div class="border-t border-indigo-700 pb-3 pt-4">
<div class="flex items-center px-5">
<div class="text-base font-medium text-white">
Hello {{ app.request.cookies.get('username', 'nobody') }} -
<a href="{{ path('username') }}" class="text-indigo-200 hover:text-white">change name</a>
</div>
</div>
</div>
</div>
</nav>
<header class="bg-white dark:bg-gray-800 shadow">
<div class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
<h1 class="text-3xl font-bold tracking-tight text-gray-900 dark:text-white">{% block header %}{% endblock %}</h1>
</div>
</header>
<main>
<div class="mx-auto max-w-7xl py-6 sm:px-6 lg:px-8">
<div class="px-4 py-6 sm:px-0">
{% block body %}{% endblock %}
</div>
</div>
</main>
</div>
</body>
</html>

View file

@ -1,45 +1,69 @@
{% extends 'base.html.twig' %}
{% block title %}FoodOrder index{% endblock %}
{% block title %}Food Orders{% endblock %}
{% block header %}Food Orders{% endblock %}
{% block body %}
<h1>FoodOrder index</h1>
<div>
<div class="sm:flex sm:items-center sm:justify-between mb-6">
<div class="min-w-0 flex-1">
<p class="text-sm text-gray-500 dark:text-gray-400">
Manage your food orders and create new ones.
</p>
</div>
<div class="mt-4 flex sm:ml-4 sm:mt-0">
<button
hx-get="{{ path('app_food_order_new') }}"
hx-trigger="click"
hx-target="closest div"
>Create new</button>
hx-target="#new-order-form"
class="block rounded-md bg-indigo-600 dark:bg-indigo-700 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 dark:hover:bg-indigo-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500"
>Create new order</button>
</div>
<hr>
<table class="table">
<thead>
</div>
<div id="new-order-form" class="mb-8"></div>
<div class="mt-8 flow-root">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<div class="overflow-hidden shadow ring-1 ring-black ring-opacity-5 dark:ring-white dark:ring-opacity-10 sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-300 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr>
<th>CreatedBy</th>
<th>Vendor</th>
<th>CreatedAt</th>
<th>ClosedAt</th>
<th>actions</th>
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 dark:text-gray-100 sm:pl-6">Created By</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Vendor</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Created At</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Closed At</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700 bg-white dark:bg-gray-800">
{% for food_order in food_orders %}
{{ include('food_order/table_row.html.twig') }}
{% endfor %}
{% if food_orders|length < 10 %}
<tr>
<td colspan="5">
check the <a href="{{ path('app_food_order_archive') }}">archive</a>
<td colspan="5" class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-500 dark:text-gray-400 sm:pl-6">
Check the <a href="{{ path('app_food_order_archive') }}" class="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300">archive</a>
for older orders
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="mt-6 flex items-center justify-center gap-x-6">
{% if prev_page > 0 %}
<a href="{{ path('app_food_order_archive', {'page': prev_page}) }}">previous page</a> |
<a href="{{ path('app_food_order_archive', {'page': prev_page}) }}" class="rounded-md bg-white dark:bg-gray-700 px-3.5 py-2.5 text-sm font-semibold text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600">Previous Page</a>
{% endif %}
{% if next_page > current_page %}
<a href="{{ path('app_food_order_archive', {'page': next_page}) }}">next page</a>
<a href="{{ path('app_food_order_archive', {'page': next_page}) }}" class="rounded-md bg-white dark:bg-gray-700 px-3.5 py-2.5 text-sm font-semibold text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600">Next Page</a>
{% endif %}
</div>
{% endblock %}

View file

@ -1,2 +1,11 @@
{{ include('_form.html.twig') }}
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-white">Create New Order</h3>
<div class="mt-2 max-w-xl text-sm text-gray-500 dark:text-gray-400">
<p>Fill out the form below to create a new food order.</p>
</div>
<div class="mt-5">
{{ include('_form.html.twig') }}
</div>
</div>
</div>

View file

@ -1,71 +1,101 @@
{% extends 'base.html.twig' %}
{% block title %}FoodOrder{% endblock %}
{% block title %}Order Details{% endblock %}
{% block header %}Order Details{% endblock %}
{% block body %}
<h1>FoodOrder</h1>
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg mb-8">
<div class="px-4 py-5 sm:px-6">
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white">Order Information</h3>
<p class="mt-1 max-w-2xl text-sm text-gray-500 dark:text-gray-400">Details about the food order from {{ food_order.foodVendor.name }}.</p>
</div>
<div class="border-t border-gray-200 dark:border-gray-700">
<dl>
<div class="bg-gray-50 dark:bg-gray-700 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-300">Vendor</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100 sm:col-span-2 sm:mt-0">{{ food_order.foodVendor.name }}</dd>
</div>
<div class="bg-white dark:bg-gray-800 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-300">Vendor Phone</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100 sm:col-span-2 sm:mt-0">{{ food_order.foodVendor.phone }}</dd>
</div>
<div class="bg-gray-50 dark:bg-gray-700 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-300">Created By</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100 sm:col-span-2 sm:mt-0">{{ food_order.createdBy }}</dd>
</div>
<div class="bg-white dark:bg-gray-800 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-300">Created At</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100 sm:col-span-2 sm:mt-0">{{ food_order.createdAt ? food_order.createdAt|date('Y-m-d H:i:s', 'Europe/Berlin') : '' }}</dd>
</div>
<div class="bg-gray-50 dark:bg-gray-700 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
<dt class="text-sm font-medium text-gray-500 dark:text-gray-300">Closed At</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100 sm:col-span-2 sm:mt-0">{{ food_order.closedAt ? food_order.closedAt|date('Y-m-d H:i:s', 'Europe/Berlin') : 'Not closed yet' }}</dd>
</div>
</dl>
</div>
</div>
<table class="table">
<tbody>
<tr>
<th>Vendor</th>
<td>{{ food_order.foodVendor.name }}</td>
</tr>
<tr>
<th>Vendorphone</th>
<td>{{ food_order.foodVendor.phone }}</td>
</tr>
<tr>
<th>Created By</th>
<td>{{ food_order.createdBy }}</td>
</tr>
<tr>
<th>CreatedAt</th>
<td>{{ food_order.createdAt ? food_order.createdAt|date('Y-m-d H:i:s', 'Europe/Berlin') : '' }}</td>
</tr>
<tr>
<th>ClosedAt</th>
<td>{{ food_order.closedAt ? food_order.closedAt|date('Y-m-d H:i:s', 'Europe/Berlin') : '' }}</td>
</tr>
</tbody>
</table>
<a class="button" href="{{ path('app_food_order_index') }}">back to list</a>
<div class="flex space-x-4 mb-8">
<a href="{{ path('app_food_order_index') }}" class="inline-flex items-center rounded-md bg-white dark:bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600">
Back to list
</a>
{% if(food_order.isClosed) %}
<a class="button" href="{{ path('app_food_order_open', {'id': food_order.id}) }}">reopen</a>
<a href="{{ path('app_food_order_open', {'id': food_order.id}) }}" class="inline-flex items-center rounded-md bg-green-600 dark:bg-green-700 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-green-500 dark:hover:bg-green-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-600 dark:focus-visible:outline-green-500">
Reopen Order
</a>
{% else %}
<a class="button" href="{{ path('app_food_order_close', {'id': food_order.id}) }}">close</a>
<a href="{{ path('app_food_order_close', {'id': food_order.id}) }}" class="inline-flex items-center rounded-md bg-red-600 dark:bg-red-700 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 dark:hover:bg-red-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600 dark:focus-visible:outline-red-500">
Close Order
</a>
{% endif %}
</div>
<h2>Items</h2>
<table class="table">
<thead>
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg mb-8">
<div class="px-4 py-5 sm:px-6 flex justify-between items-center">
<div>
<h3 class="text-lg font-medium leading-6 text-gray-900 dark:text-white">Order Items</h3>
<p class="mt-1 max-w-2xl text-sm text-gray-500 dark:text-gray-400">Items included in this order.</p>
</div>
<div>
<a href="{{ path('app_order_item_new', {'foodOrder': food_order.id}) }}" class="inline-flex items-center rounded-md bg-indigo-600 dark:bg-indigo-700 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 dark:hover:bg-indigo-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500">
Add New Item
</a>
</div>
</div>
<div class="overflow-hidden shadow ring-1 ring-black ring-opacity-5 dark:ring-white dark:ring-opacity-10">
<table class="min-w-full divide-y divide-gray-300 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th>Index</th>
<th>username</th>
<th>name</th>
<th>extras</th>
<th>actions</th>
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 dark:text-gray-100 sm:pl-6">#</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Username</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Item Name</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Extras</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700 bg-white dark:bg-gray-800">
{% for item in food_order.orderItemsSortedByName %}
<tr>
<td>{{ loop.index }}</td>
<td>{{ item.createdBy }}</td>
<td>{{ item.name }}</td>
<td>{{ item.extras }}</td>
<td>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 dark:text-gray-100 sm:pl-6">{{ loop.index }}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">{{ item.createdBy }}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">{{ item.name }}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">{{ item.extras }}</td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
{% if(food_order.isClosed) %}
<span class="text-gray-400 dark:text-gray-500">Order closed</span>
{% else %}
<a href="{{ path('app_order_item_edit', {'id': item.id}) }}">edit</a>
<a href="{{ path('app_order_item_copy', {'id': item.id}) }}">copy</a>
<a href="{{ path('app_order_item_delete', {'id': item.id}) }}">remove</a>
<a href="{{ path('app_order_item_edit', {'id': item.id}) }}" class="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 mr-2">Edit</a>
<a href="{{ path('app_order_item_copy', {'id': item.id}) }}" class="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 mr-2">Copy</a>
<a href="{{ path('app_order_item_delete', {'id': item.id}) }}" class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300">Remove</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<a class="button" href="{{ path('app_order_item_new', {'foodOrder': food_order.id}) }}">New Item</a>
</div>
</div>
{% endblock %}

View file

@ -1,9 +1,9 @@
<tr>
<td>{{ food_order.createdBy }}</td>
<td>{{ food_order.foodVendor.name }}</td>
<td>{{ food_order.createdAt ? food_order.createdAt|date('Y-m-d H:i:s', 'Europe/Berlin') : '' }}</td>
<td>{{ food_order.closedAt ? food_order.closedAt|date('Y-m-d H:i:s', 'Europe/Berlin') : '' }}</td>
<td>
<a href="{{ path('app_food_order_show', {'id': food_order.id}) }}">show</a>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 dark:text-gray-100 sm:pl-6">{{ food_order.createdBy }}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">{{ food_order.foodVendor.name }}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">{{ food_order.createdAt ? food_order.createdAt|date('Y-m-d H:i:s', 'Europe/Berlin') : '' }}</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">{{ food_order.closedAt ? food_order.closedAt|date('Y-m-d H:i:s', 'Europe/Berlin') : '' }}</td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
<a href="{{ path('app_food_order_show', {'id': food_order.id}) }}" class="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300">View<span class="sr-only">, order from {{ food_order.createdBy }}</span></a>
</td>
</tr>

View file

@ -1,4 +1,24 @@
{{ form_start(form) }}
{{ form_widget(form) }}
<button class="btn">{{ button_label|default('Save') }}</button>
{{ form_start(form, {'attr': {'class': 'space-y-6'}}) }}
{% for field in form %}
{% if field.vars.name != '_token' %}
<div class="space-y-2">
{{ form_label(field, null, {'label_attr': {'class': 'block text-sm font-medium leading-6 text-gray-900'}}) }}
<div>
{{ form_widget(field, {'attr': {'class': 'block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6'}}) }}
</div>
{% if field.vars.errors|length > 0 %}
<div class="mt-1 text-sm text-red-600">
{{ form_errors(field) }}
</div>
{% endif %}
{% if field.vars.help is defined and field.vars.help %}
<p class="mt-1 text-sm text-gray-500">{{ field.vars.help }}</p>
{% endif %}
</div>
{% endif %}
{% endfor %}
<div class="mt-6 flex items-center justify-end gap-x-6">
<a href="{{ app.request.headers.get('referer', '/') }}" class="text-sm font-semibold leading-6 text-gray-900">Cancel</a>
<button type="submit" class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">{{ button_label|default('Save') }}</button>
</div>
{{ form_end(form) }}

View file

@ -1,33 +1,54 @@
{% extends 'base.html.twig' %}
{% block title %}FoodVendor index{% endblock %}
{% block title %}Food Vendors{% endblock %}
{% block header %}Food Vendors{% endblock %}
{% block body %}
<h1>FoodVendor index</h1>
<div class="sm:flex sm:items-center sm:justify-between mb-6">
<div class="min-w-0 flex-1">
<p class="text-sm text-gray-500 dark:text-gray-400">
Manage your food vendors and create new ones.
</p>
</div>
<div class="mt-4 flex sm:ml-4 sm:mt-0">
<a href="{{ path('app_food_vendor_new') }}" class="block rounded-md bg-indigo-600 dark:bg-indigo-700 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 dark:hover:bg-indigo-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500">
Create new vendor
</a>
</div>
</div>
<table class="table">
<thead>
<div class="mt-8 flow-root">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<div class="overflow-hidden shadow ring-1 ring-black ring-opacity-5 dark:ring-white dark:ring-opacity-10 sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-300 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr>
<th>Name</th>
<th>actions</th>
<th scope="col" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 dark:text-gray-100 sm:pl-6">Name</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700 bg-white dark:bg-gray-800">
{% for food_vendor in food_vendors %}
<tr>
<td>{{ food_vendor.name }}</td>
<td>
<a href="{{ path('app_food_vendor_show', {'id': food_vendor.id}) }}">show</a>
<a href="{{ path('app_food_vendor_edit', {'id': food_vendor.id}) }}">edit</a>
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 dark:text-gray-100 sm:pl-6">{{ food_vendor.name }}</td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6">
<a href="{{ path('app_food_vendor_show', {'id': food_vendor.id}) }}" class="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 mr-2">View</a>
<a href="{{ path('app_food_vendor_edit', {'id': food_vendor.id}) }}" class="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300">Edit</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="3">no records found</td>
<td colspan="2" class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-500 dark:text-gray-400 sm:pl-6">No vendors found</td>
</tr>
{% endfor %}
</tbody>
</table>
<a href="{{ path('app_food_vendor_new') }}">Create new</a>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -2,8 +2,15 @@
{% block title %}Tell me your name{% endblock %}
{% block header %}Tell me your name{% endblock %}
{% block body %}
<h1>Tell me your name</h1>
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<div class="max-w-xl text-sm text-gray-500 dark:text-gray-400 mb-6">
<p>By submitting the form, you agree that your username will be stored as a cookie.</p>
</div>
{{ include('_form.html.twig') }}
</div>
</div>
{% endblock %}

26
tests/DbApiTestCase.php Normal file
View file

@ -0,0 +1,26 @@
<?php declare(strict_types=1);
namespace App\Tests;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use ApiPlatform\Symfony\Bundle\Test\Client;
use App\DataFixtures\AppFixtures;
use Doctrine\ORM\EntityManagerInterface;
use Liip\TestFixturesBundle\Services\DatabaseToolCollection;
use Override;
abstract class DbApiTestCase extends ApiTestCase
{
protected EntityManagerInterface $manager;
protected Client $client;
#[Override]
protected function setUp(): void
{
parent::setUp();
$this->client = static::createClient();
$this->manager = static::getContainer()->get('doctrine')->getManager();
$toolKit = self::getContainer()->get(DatabaseToolCollection::class)->get();
$toolKit->loadFixtures([AppFixtures::class]);
}
}

View file

@ -0,0 +1,48 @@
<?php declare(strict_types=1);
test('orders', function (): void {
$response = $this->client->request('GET', '/api/food_orders');
$this->assertResponseIsSuccessful();
$this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8');
$this->assertJsonContains([
'@context' => '/api/contexts/FoodOrder',
'@id' => '/api/food_orders',
'@type' => 'Collection',
'totalItems' => 1,
]);
$array = $response->toArray();
expect($array['member'][0]['orderItems'])->toHaveCount(10);
});
test('open orders', function (): void {
$response = $this->client->request('GET', '/api/food_orders/open');
$this->assertResponseIsSuccessful();
$this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8');
$this->assertJsonContains([
'@context' => '/api/contexts/FoodOrder',
'@id' => '/api/food_orders/open',
'@type' => 'Collection',
]);
// Get the response content and verify that all returned orders are open
$array = $response->toArray();
// If we have no items, we should still have a successful response
if ($array['totalItems'] === 0) {
return;
}
// For each order in the response, check that it is not closed
foreach ($array['member'] as $order) {
// An order is considered open if either:
// 1. closedAt is null or
// 2. closedAt is in the future
$closedAt = isset($order['closedAt']) ? new DateTimeImmutable($order['closedAt']) : null;
$now = new DateTimeImmutable;
// Assert that the order is open (not closed)
$isOpen = (! $closedAt instanceof DateTimeImmutable || $closedAt > $now);
expect($isOpen)
->toBeTrue('Order should be open but is closed');
}
});

View file

@ -44,7 +44,7 @@ describe(FoodOrderController::class, function (): void {
$crawler = $this->client->request('GET', "{$this->path}list");
$this->assertResponseStatusCodeSame(200);
$this->assertPageTitleContains('FoodOrder index');
$this->assertPageTitleContains('Food Orders');
$this->assertCount(
1,
$crawler->filter('td')
@ -99,17 +99,29 @@ describe(FoodOrderController::class, function (): void {
$crawler = $this->client->request('GET', "{$this->path}{$order->getId()}");
$this->assertResponseIsSuccessful();
$tdContent = $crawler->filter(
'table.table:nth-child(6) > tbody:nth-child(2) > tr:nth-child(1) > td:nth-child(3)'
)->text();
// Find all tables and get the last one (order items table)
$tables = $crawler->filter('table');
$lastTable = $tables->eq($tables->count() - 1);
// Get the item names from the table rows
$rows = $lastTable->filter('tbody tr');
$tdContent = $rows->eq(0)
->filter('td')
->eq(2)
->text();
$this->assertEquals('A', $tdContent);
$tdContent = $crawler->filter(
'table.table:nth-child(6) > tbody:nth-child(2) > tr:nth-child(2) > td:nth-child(3)'
)->text();
$tdContent = $rows->eq(1)
->filter('td')
->eq(2)
->text();
$this->assertEquals('B', $tdContent);
$tdContent = $crawler->filter(
'table.table:nth-child(6) > tbody:nth-child(2) > tr:nth-child(3) > td:nth-child(3)'
)->text();
$tdContent = $rows->eq(2)
->filter('td')
->eq(2)
->text();
$this->assertEquals('C', $tdContent);
});
@ -124,12 +136,12 @@ describe(FoodOrderController::class, function (): void {
$this->manager->flush();
$crawler = $this->client->request('GET', "{$this->path}list");
$this->assertResponseStatusCodeSame(200);
$this->assertPageTitleContains('FoodOrder index');
$this->assertPageTitleContains('Food Orders');
$this->assertElementContainsCount(
$crawler,
'td',
1,
'older orders'
'for older orders'
);
$this->assertElementContainsCount(
$crawler,
@ -139,7 +151,7 @@ describe(FoodOrderController::class, function (): void {
);
});
test('paginatedFirstPage', function (int $page, int $prevPage, int $nextPage, int $items = 10): void {
test('paginatedFirstPage', function (int $page, int $prevPage, int $nextPage, int $items = 20): void {
foreach (range(1, 35) as $i) {
$order = new FoodOrder($this->generateOldUlid());
$order->setFoodVendor($this->vendor);
@ -150,7 +162,7 @@ describe(FoodOrderController::class, function (): void {
$this->manager->flush();
$crawler = $this->client->request('GET', "{$this->path}list/archive/{$page}");
$this->assertResponseStatusCodeSame(200);
$this->assertPageTitleContains('FoodOrder index');
$this->assertPageTitleContains('Food Orders');
$this->assertElementContainsCount(
$crawler,
'td',
@ -160,14 +172,14 @@ describe(FoodOrderController::class, function (): void {
if ($prevPage > 0) {
$prevPage = $prevPage === 1 ? '' : "/{$prevPage}";
$node = $crawler->filter('a')
->reduce(static fn(Crawler $node, $i): bool => $node->text() === 'previous page')
->reduce(static fn(Crawler $node, $i): bool => $node->text() === 'Previous Page')
->first();
$target = $node->attr('href');
$this->assertTrue(str_ends_with((string) $target, $prevPage));
}
if ($prevPage > 3) {
$node = $crawler->filter('a')
->reduce(static fn(Crawler $node, $i): bool => $node->text() === 'next page')
->reduce(static fn(Crawler $node, $i): bool => $node->text() === 'Next Page')
->first();
$target = $node->attr('href');
$this->assertTrue(str_ends_with((string) $target, "/{$nextPage}"));
@ -178,7 +190,7 @@ describe(FoodOrderController::class, function (): void {
[1, 0, 2],
[2, 1, 3],
[3, 2, 4],
[4, 3, 0, 5],
[4, 3, 0, 10],
]
);
@ -231,30 +243,6 @@ describe(FoodOrderController::class, function (): void {
$this->assertTrue($openOrder->isClosed());
});
test('api_latest_order', function (): void {
// Create an older order
$olderOrder = new FoodOrder($this->generateOldUlid());
$olderOrder->setFoodVendor($this->vendor);
$this->manager->persist($olderOrder);
// Create the latest order
$latestOrder = new FoodOrder;
$latestOrder->setFoodVendor($this->vendor);
$this->manager->persist($latestOrder);
$this->manager->flush();
// Test the API endpoint
$this->client->request('GET', '/api/food_orders/latest');
$this->assertResponseIsSuccessful();
$this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8');
$response = json_decode($this->client->getResponse()->getContent(), true);
$this->assertIsArray($response);
$this->assertArrayHasKey('@id', $response);
$this->assertStringContainsString($latestOrder->getId()->__toString(), $response['@id']);
});
})
->covers(
FoodOrderController::class,

View file

@ -29,7 +29,7 @@ describe(FoodVendorController::class, function (): void {
$this->client->request('GET', $this->path);
$this->assertResponseStatusCodeSame(200);
$this->assertPageTitleContains('FoodVendor index');
$this->assertPageTitleContains('Food Vendors');
});
test('new', function (): void {

View file

@ -136,10 +136,9 @@ describe(MenuItemController::class, function (): void {
$this->assertTrue($menuItem->isDeleted());
$crawler = $this->client->request('GET', '/order/item/new/' . $order->getId());
$count = $crawler->filter('body > main:nth-child(2) > div:nth-child(5)')
->children()
$count = $crawler->filter('form')
->count();
$this->assertSame(2, $count);
$this->assertSame(1, $count);
$this->assertResponseIsSuccessful();

View file

@ -60,8 +60,7 @@ describe(OrderItemController::class, function (): void {
sprintf('%snew/%s', $this->path, $this->order->getId())
);
$children = $crawler->filter('body > main:nth-child(2) > div:nth-child(5)')
->children();
$children = $crawler->filter('form');
$this->assertCount(1, $children);

View file

@ -1,5 +1,6 @@
<?php declare(strict_types=1);
use App\Tests\DbApiTestCase;
use App\Tests\DbWebTest;
/*
@ -15,6 +16,8 @@ use App\Tests\DbWebTest;
pest()
->extends(DbWebTest::class)->in('Feature/Controller/*.php');
pest()
->extends(DbApiTestCase::class)->in('Feature/Api/*.php');
/*
|--------------------------------------------------------------------------

View file

@ -0,0 +1,21 @@
<?php declare(strict_types=1);
test('example test', function (): void {
// This is a simple example test
expect(true)
->toBeTrue();
expect(1 + 1)
->toBe(2);
expect('hello world')
->toContain('world');
});
test('array operations', function (): void {
$array = [1, 2, 3];
expect($array)
->toHaveCount(3);
expect($array)
->toContain(2);
expect($array[0])->toBe(1);
});