Add Livewire Flux components and new tests for project proposal and editing forms

This commit is contained in:
HolgerHatGarKeineNode
2026-01-18 15:19:00 +01:00
parent 30e78711c9
commit 0694a2d837
19 changed files with 816 additions and 444 deletions

View File

@@ -14,6 +14,8 @@ This application is a Laravel application and its main Laravel ecosystems packag
- laravel/prompts (PROMPTS) - v0
- laravel/reverb (REVERB) - v1
- laravel/sail (SAIL) - v1
- livewire/flux (FLUXUI_FREE) - v2
- livewire/flux-pro (FLUXUI_PRO) - v2
- livewire/livewire (LIVEWIRE) - v4
- laravel/mcp (MCP) - v0
- laravel/pint (PINT) - v1
@@ -124,6 +126,13 @@ protected function isAccessible(User $user, ?string $path = null): bool
- Execute PHP scripts: `vendor/bin/sail php [script]`
- View all available Sail commands by running `vendor/bin/sail` without arguments.
=== tests rules ===
## Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test --compact` with a specific filename or filter.
=== laravel/core rules ===
## Do Things the Laravel Way
@@ -191,6 +200,28 @@ protected function isAccessible(User $user, ?string $path = null): bool
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== fluxui-pro/core rules ===
## Flux UI Pro
- This project is using the Pro version of Flux UI. It has full access to the free components and variants, as well as full access to the Pro components and variants.
- Flux UI is a component library for Livewire. Flux is a robust, hand-crafted UI component library for your Livewire applications. It's built using Tailwind CSS and provides a set of components that are easy to use and customize.
- You should use Flux UI components when available.
- Fallback to standard Blade components if Flux is unavailable.
- If available, use the `search-docs` tool to get the exact documentation and code snippets available for this project.
- Flux UI components look like this:
<code-snippet name="Flux UI Component Example" lang="blade">
<flux:button variant="primary"/>
</code-snippet>
### Available Components
This is correct as of Boost installation, but there may be additional components within the codebase.
<available-flux-components>
accordion, autocomplete, avatar, badge, brand, breadcrumbs, button, calendar, callout, card, chart, checkbox, command, composer, context, date-picker, dropdown, editor, field, file-upload, heading, icon, input, kanban, modal, navbar, otp-input, pagination, pillbox, popover, profile, radio, select, separator, skeleton, slider, switch, table, tabs, text, textarea, time-picker, toast, tooltip
</available-flux-components>
=== livewire/core rules ===
## Livewire

View File

@@ -14,6 +14,8 @@ This application is a Laravel application and its main Laravel ecosystems packag
- laravel/prompts (PROMPTS) - v0
- laravel/reverb (REVERB) - v1
- laravel/sail (SAIL) - v1
- livewire/flux (FLUXUI_FREE) - v2
- livewire/flux-pro (FLUXUI_PRO) - v2
- livewire/livewire (LIVEWIRE) - v4
- laravel/mcp (MCP) - v0
- laravel/pint (PINT) - v1
@@ -124,6 +126,13 @@ protected function isAccessible(User $user, ?string $path = null): bool
- Execute PHP scripts: `vendor/bin/sail php [script]`
- View all available Sail commands by running `vendor/bin/sail` without arguments.
=== tests rules ===
## Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test --compact` with a specific filename or filter.
=== laravel/core rules ===
## Do Things the Laravel Way
@@ -191,6 +200,28 @@ protected function isAccessible(User $user, ?string $path = null): bool
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== fluxui-pro/core rules ===
## Flux UI Pro
- This project is using the Pro version of Flux UI. It has full access to the free components and variants, as well as full access to the Pro components and variants.
- Flux UI is a component library for Livewire. Flux is a robust, hand-crafted UI component library for your Livewire applications. It's built using Tailwind CSS and provides a set of components that are easy to use and customize.
- You should use Flux UI components when available.
- Fallback to standard Blade components if Flux is unavailable.
- If available, use the `search-docs` tool to get the exact documentation and code snippets available for this project.
- Flux UI components look like this:
<code-snippet name="Flux UI Component Example" lang="blade">
<flux:button variant="primary"/>
</code-snippet>
### Available Components
This is correct as of Boost installation, but there may be additional components within the codebase.
<available-flux-components>
accordion, autocomplete, avatar, badge, brand, breadcrumbs, button, calendar, callout, card, chart, checkbox, command, composer, context, date-picker, dropdown, editor, field, file-upload, heading, icon, input, kanban, modal, navbar, otp-input, pagination, pillbox, popover, profile, radio, select, separator, skeleton, slider, switch, table, tabs, text, textarea, time-picker, toast, tooltip
</available-flux-components>
=== livewire/core rules ===
## Livewire

View File

@@ -14,6 +14,8 @@ This application is a Laravel application and its main Laravel ecosystems packag
- laravel/prompts (PROMPTS) - v0
- laravel/reverb (REVERB) - v1
- laravel/sail (SAIL) - v1
- livewire/flux (FLUXUI_FREE) - v2
- livewire/flux-pro (FLUXUI_PRO) - v2
- livewire/livewire (LIVEWIRE) - v4
- laravel/mcp (MCP) - v0
- laravel/pint (PINT) - v1
@@ -124,6 +126,13 @@ protected function isAccessible(User $user, ?string $path = null): bool
- Execute PHP scripts: `vendor/bin/sail php [script]`
- View all available Sail commands by running `vendor/bin/sail` without arguments.
=== tests rules ===
## Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test --compact` with a specific filename or filter.
=== laravel/core rules ===
## Do Things the Laravel Way
@@ -191,6 +200,28 @@ protected function isAccessible(User $user, ?string $path = null): bool
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== fluxui-pro/core rules ===
## Flux UI Pro
- This project is using the Pro version of Flux UI. It has full access to the free components and variants, as well as full access to the Pro components and variants.
- Flux UI is a component library for Livewire. Flux is a robust, hand-crafted UI component library for your Livewire applications. It's built using Tailwind CSS and provides a set of components that are easy to use and customize.
- You should use Flux UI components when available.
- Fallback to standard Blade components if Flux is unavailable.
- If available, use the `search-docs` tool to get the exact documentation and code snippets available for this project.
- Flux UI components look like this:
<code-snippet name="Flux UI Component Example" lang="blade">
<flux:button variant="primary"/>
</code-snippet>
### Available Components
This is correct as of Boost installation, but there may be additional components within the codebase.
<available-flux-components>
accordion, autocomplete, avatar, badge, brand, breadcrumbs, button, calendar, callout, card, chart, checkbox, command, composer, context, date-picker, dropdown, editor, field, file-upload, heading, icon, input, kanban, modal, navbar, otp-input, pagination, pillbox, popover, profile, radio, select, separator, skeleton, slider, switch, table, tabs, text, textarea, time-picker, toast, tooltip
</available-flux-components>
=== livewire/core rules ===
## Livewire

View File

@@ -9,19 +9,15 @@ use PowerComponents\LivewirePowerGrid\Button;
use PowerComponents\LivewirePowerGrid\Column;
use PowerComponents\LivewirePowerGrid\Detail;
use PowerComponents\LivewirePowerGrid\Exportable;
use PowerComponents\LivewirePowerGrid\Facades\Rule;
use PowerComponents\LivewirePowerGrid\Footer;
use PowerComponents\LivewirePowerGrid\Header;
use PowerComponents\LivewirePowerGrid\PowerGrid;
use PowerComponents\LivewirePowerGrid\PowerGridFields;
use PowerComponents\LivewirePowerGrid\PowerGridComponent;
use PowerComponents\LivewirePowerGrid\Traits\WithExport;
use WireUi\Traits\WireUiActions;
final class EinundzwanzigPlebTable extends PowerGridComponent
class EinundzwanzigPlebTable extends PowerGridComponent
{
use WireUiActions;
use WithExport;
public string $sortField = 'association_status';
public string $sortDirection = 'desc';
public string $sortField = 'association_status';
@@ -49,8 +45,7 @@ final class EinundzwanzigPlebTable extends PowerGridComponent
return EinundzwanzigPleb::query()
->with([
'profile',
'paymentEvents' => fn($query)
=> $query
'paymentEvents' => fn ($query) => $query
->where('year', date('Y'))
->where('paid', true),
])
@@ -64,41 +59,37 @@ final class EinundzwanzigPlebTable extends PowerGridComponent
->add('pubkey')
->add(
'avatar',
fn($model,
)
=> '<img class="w-8 h-8 shrink-0 grow-0 rounded-full" onerror="this.onerror=null; this.src=\'https://robohash.org/test\'";" src="'.asset(
$model->profile?->picture,
).'">',
fn ($model,
) => '<img class="w-8 h-8 shrink-0 grow-0 rounded-full" onerror="this.onerror=null; this.src=\'https://robohash.org/test\'";" src="'.asset(
$model->profile?->picture,
).'">',
)
->add(
'for',
fn($model,
)
=> $model->application_for ? '<div class="m-1.5"><div class="text-xs inline-flex font-medium bg-red-500/20 text-red-700 rounded-full text-center px-2.5 py-1">'.AssociationStatus::from(
$model->application_for,
)->label().'</div></div>' : '',
fn ($model,
) => $model->application_for ? '<div class="m-1.5"><div class="text-xs inline-flex font-medium bg-red-500/20 text-red-700 rounded-full text-center px-2.5 py-1">'.AssociationStatus::from(
$model->application_for,
)->label().'</div></div>' : '',
)
->add(
'payment',
fn(EinundzwanzigPleb $model)
=> $model->paymentEvents->count() > 0 && $model->paymentEvents->first()->paid ? '<span class="text-green-500">'.number_format(
$model->paymentEvents->first()->amount,
0,
',',
'.',
).'</span>' : 'keine Zahlung vorhanden',
fn (EinundzwanzigPleb $model) => $model->paymentEvents->count() > 0 && $model->paymentEvents->first()->paid ? '<span class="text-green-500">'.number_format(
$model->paymentEvents->first()->amount,
0,
',',
'.',
).'</span>' : 'keine Zahlung vorhanden',
)
->add('npub_export', fn(EinundzwanzigPleb $model) => $model->npub)
->add('npub_export', fn (EinundzwanzigPleb $model) => $model->npub)
->add(
'npub',
fn(EinundzwanzigPleb $model)
=> '<a target="_blank" class="btn-xs bg-gray-900 text-gray-100 hover:bg-gray-800 dark:bg-gray-100 dark:text-gray-800 dark:hover:bg-white" href="https://nostrudel.ninja/u/'.e(
$model->npub,
).'">Nostr Profile</a>',
fn (EinundzwanzigPleb $model) => '<a target="_blank" class="btn-xs bg-gray-900 text-gray-100 hover:bg-gray-800 dark:bg-gray-100 dark:text-gray-800 dark:hover:bg-white" href="https://nostrudel.ninja/u/'.e(
$model->npub,
).'">Nostr Profile</a>',
)
->add('association_status')
->add('association_status_name', fn(EinundzwanzigPleb $model) => $model->association_status->name)
->add('paid_export', fn(EinundzwanzigPleb $model) => $model->paymentEvents->first()?->amount)
->add('association_status_name', fn (EinundzwanzigPleb $model) => $model->association_status->name)
->add('paid_export', fn (EinundzwanzigPleb $model) => $model->paymentEvents->first()?->amount)
->add(
'association_status_formatted',
function (EinundzwanzigPleb $model) {
@@ -109,13 +100,13 @@ final class EinundzwanzigPlebTable extends PowerGridComponent
AssociationStatus::HONORARY => 'text-xs inline-flex font-medium rounded-full text-center px-2.5 py-1 bg-blue-500/20 text-blue-700',
default => 'text-xs inline-flex font-medium rounded-full text-center px-2.5 py-1 text-red-700',
};
return '<span class="'.$class.'">'.$model->association_status->label().'</span>';
},
)
->add(
'name_lower',
fn(EinundzwanzigPleb $model)
=> strtolower(
fn (EinundzwanzigPleb $model) => strtolower(
e($model->profile?->name ?: $model->profile?->display_name ?? ''),
),
);
@@ -251,9 +242,8 @@ final class EinundzwanzigPlebTable extends PowerGridComponent
return [
// Hide button edit for ID 1
Rule::button('accept')
->when(fn($row) => $row->application_for === null)
->when(fn ($row) => $row->application_for === null)
->hide(),
];
}
}

View File

@@ -17,6 +17,8 @@
"laravel/reverb": "^1.0",
"laravel/sail": "^1.31",
"laravel/tinker": "^2.9",
"livewire/flux": "^2.10",
"livewire/flux-pro": "^2.10",
"livewire/livewire": "^4.0",
"openspout/openspout": "^4.24",
"power-components/livewire-powergrid": "^6.7",
@@ -33,8 +35,7 @@
"spatie/laravel-sluggable": "^3.6",
"spatie/laravel-tags": "^4.9.2",
"staudenmeir/eloquent-has-many-deep": "^1.7",
"swentel/nostr-php": "^1.4",
"wireui/wireui": "^2.5.1"
"swentel/nostr-php": "^1.4"
},
"require-dev": {
"fakerphp/faker": "^1.23",
@@ -95,5 +96,10 @@
}
},
"minimum-stability": "stable",
"prefer-stable": true
"prefer-stable": true,
"repositories": [{
"name": "flux-pro",
"type": "composer",
"url": "https://composer.fluxui.dev"
}]
}

270
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "7ce22785474454dbae00168baafb6ccf",
"content-hash": "39a9ce519dbfeb237966b7441b59a562",
"packages": [
{
"name": "akuechler/laravel-geoly",
@@ -3025,6 +3025,145 @@
],
"time": "2026-01-15T06:54:53+00:00"
},
{
"name": "livewire/flux",
"version": "v2.10.2",
"source": {
"type": "git",
"url": "https://github.com/livewire/flux.git",
"reference": "e7a93989788429bb6c0a908a056d22ea3a6c7975"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/livewire/flux/zipball/e7a93989788429bb6c0a908a056d22ea3a6c7975",
"reference": "e7a93989788429bb6c0a908a056d22ea3a6c7975",
"shasum": ""
},
"require": {
"illuminate/console": "^10.0|^11.0|^12.0",
"illuminate/support": "^10.0|^11.0|^12.0",
"illuminate/view": "^10.0|^11.0|^12.0",
"laravel/prompts": "^0.1|^0.2|^0.3",
"livewire/livewire": "^3.7.3|^4.0",
"php": "^8.1",
"symfony/console": "^6.0|^7.0"
},
"conflict": {
"livewire/blaze": "<1.0.0"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Flux": "Flux\\Flux"
},
"providers": [
"Flux\\FluxServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Flux\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"proprietary"
],
"authors": [
{
"name": "Caleb Porzio",
"email": "calebporzio@gmail.com"
}
],
"description": "The official UI component library for Livewire.",
"keywords": [
"components",
"flux",
"laravel",
"livewire",
"ui"
],
"support": {
"issues": "https://github.com/livewire/flux/issues",
"source": "https://github.com/livewire/flux/tree/v2.10.2"
},
"time": "2025-12-19T02:11:45+00:00"
},
{
"name": "livewire/flux-pro",
"version": "2.10.2",
"dist": {
"type": "zip",
"url": "https://composer.fluxui.dev/download/a0a0798f-1cf8-4999-8f57-8688c21f2d59/flux-pro-2.10.2.zip",
"reference": "9440435e467c4bb775efbc2c510ec7dea61e17d7",
"shasum": "177bf08ae75c628c96a1a3a4fd1e788b1b798e1d"
},
"require": {
"illuminate/console": "^10.0|^11.0|^12.0",
"illuminate/support": "^10.0|^11.0|^12.0",
"illuminate/view": "^10.0|^11.0|^12.0",
"laravel/prompts": "^0.1.24|^0.2|^0.3",
"livewire/flux": "2.10.2|dev-main",
"livewire/livewire": "^3.7.3|^4.0",
"php": "^8.1",
"symfony/console": "^6.0|^7.0"
},
"require-dev": {
"livewire/volt": "*",
"orchestra/testbench": "^10.8"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Flux": "FluxPro\\FluxPro"
},
"providers": [
"FluxPro\\FluxProServiceProvider"
]
}
},
"autoload": {
"files": [
"src/helpers.php"
],
"psr-4": {
"FluxPro\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\": "workbench/app/"
}
},
"scripts": {
"serve": [
"Composer\\Config::disableProcessTimeout",
"@php vendor/bin/testbench workbench:build --ansi",
"@php vendor/bin/testbench serve --port 3000 --ansi"
]
},
"license": [
"proprietary"
],
"authors": [
{
"name": "Caleb Porzio",
"email": "calebporzio@gmail.com"
}
],
"description": "The pro version of Flux, the official UI component library for Livewire.",
"keywords": [
"components",
"flux",
"laravel",
"livewire",
"ui"
],
"time": "2025-12-19T02:22:27+00:00"
},
{
"name": "livewire/livewire",
"version": "v4.0.1",
@@ -10924,135 +11063,6 @@
}
],
"time": "2024-11-21T01:49:47+00:00"
},
{
"name": "wireui/heroicons",
"version": "v2.9.0",
"source": {
"type": "git",
"url": "https://github.com/wireui/heroicons.git",
"reference": "ccd2ab94293d6f231271c0847c1db34305313c6f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/wireui/heroicons/zipball/ccd2ab94293d6f231271c0847c1db34305313c6f",
"reference": "ccd2ab94293d6f231271c0847c1db34305313c6f",
"shasum": ""
},
"require": {
"laravel/framework": "^9.16|^10.0|^11.0|^12.0",
"php": "^8.1|^8.2|^8.3|^8.4"
},
"require-dev": {
"larastan/larastan": "^3.0",
"laravel/pint": "^1.6",
"orchestra/testbench": "^10.0",
"pestphp/pest": "^3.0"
},
"type": "library",
"extra": {
"aliases": [],
"laravel": {
"providers": [
"WireUi\\Heroicons\\HeroiconsServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"WireUi\\Heroicons\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Pedro Oliveira",
"email": "pedrolivertwd@gmail.com"
}
],
"description": "The Tailwind Heroicons for laravel blade by WireUI",
"keywords": [
"blade components",
"blade heroicons",
"laravel components",
"livewire icons",
"livewire icons components",
"wireui"
],
"support": {
"issues": "https://github.com/wireui/heroicons/issues",
"source": "https://github.com/wireui/heroicons/tree/v2.9.0"
},
"time": "2025-03-02T22:06:22+00:00"
},
{
"name": "wireui/wireui",
"version": "v2.5.1",
"source": {
"type": "git",
"url": "https://github.com/wireui/wireui.git",
"reference": "e4d966689eb840986281f5237149b2480eacf87c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/wireui/wireui/zipball/e4d966689eb840986281f5237149b2480eacf87c",
"reference": "e4d966689eb840986281f5237149b2480eacf87c",
"shasum": ""
},
"require": {
"laravel/framework": "^10.0|^11.0|^12.0",
"php": "^8.2|^8.3|^8.4|^8.5",
"wireui/heroicons": "^2.8"
},
"require-dev": {
"laravel/pint": "^1.19",
"livewire/livewire": "^3.6",
"orchestra/testbench": "^10.0",
"orchestra/testbench-dusk": "^10.0",
"pestphp/pest": "^4.0",
"pestphp/pest-plugin-laravel": "^4.0",
"pestphp/pest-plugin-livewire": "^4.0"
},
"type": "library",
"extra": {
"laravel": {
"aliases": [],
"providers": [
"WireUi\\ServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"WireUi\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Pedro Oliveira",
"email": "pedrolivertwd@gmail.com"
}
],
"description": "TallStack components",
"keywords": [
"blade components",
"laravel components",
"livewire components",
"livewire-ui",
"wireui"
],
"support": {
"issues": "https://github.com/wireui/wireui/issues",
"source": "https://github.com/wireui/wireui/tree/v2.5.1"
},
"time": "2025-11-10T04:46:35+00:00"
}
],
"packages-dev": [

View File

@@ -70,14 +70,18 @@
@if(\App\Support\NostrAuth::check())
<form method="post" action="{{ route('logout') }}"
@submit="$dispatch('nostrLoggedOut')">
@csrf
<x-button secondary label="Logout" type="submit"/>
</form>
@else
<x-button wire:key="loginBtn" label="Mit Nostr verbinden" @click="openNostrLogin"
x-show="!$store.nostr.user"/>
<form method="post" action="{{ route('logout') }}"
@submit="$dispatch('nostrLoggedOut')">
@csrf
<flux:button secondary type="submit">
Logout
</flux:button>
</form>
@else
<flux:button wire:key="loginBtn" @click="openNostrLogin"
x-show="!$store.nostr.user">
Mit Nostr verbinden
</flux:button>
@endif
<!-- Info button -->

View File

@@ -88,26 +88,30 @@
@if(
($currentPleb && $currentPleb->id === $project->einundzwanzig_pleb_id)
|| ($currentPleb && in_array($currentPleb->npub, config('einundzwanzig.config.current_board'), true))
)
<x-button
)
<flux:button
icon="trash"
xs
negative
wire:click="confirmDelete({{ $project->id }})"
label="Löschen"/>
<x-button
wire:loading.attr="disabled">
Löschen
</flux:button>
<flux:button
icon="pencil"
xs
secondary
:href="route('association.projectSupport.edit', ['projectProposal' => $project])"
label="Editieren"/>
:href="route('association.projectSupport.edit', ['projectProposal' => $project])">
Editieren
</flux:button>
@endif
@if(($currentPleb && $currentPleb->association_status->value > 2) || $project->accepted)
<x-button
<flux:button
icon="folder-open"
xs
:href="route('association.projectSupport.item', ['projectProposal' => $project])"
label="Öffnen"/>
:href="route('association.projectSupport.item', ['projectProposal' => $project])">
Öffnen
</flux:button>
@endif
</div>
<div class="py-2">

View File

@@ -3,11 +3,10 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{!! seo($seo ?? null) !!}
{!! seo($seo ?? null) !}
<title>{{ $title ?? 'Page Title' }}</title>
@livewireStyles
@wireUiScripts
@stack('scripts')
@vite(['resources/js/app.js','resources/css/app.css'])
@googlefonts
@@ -15,183 +14,92 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.css">
<script src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script>
@include('components.layouts.partials.styles')
@fluxAppearance
</head>
<body
class="font-sans antialiased bg-gray-100 dark:bg-[#222222] text-gray-600 dark:text-gray-400"
:class="{ 'sidebar-expanded': sidebarExpanded }"
x-data="{ sidebarOpen: false, sidebarExpanded: localStorage.getItem('sidebar-expanded') == 'true', inboxSidebarOpen: false }"
x-init="$watch('sidebarExpanded', value => localStorage.setItem('sidebar-expanded', value))"
<body class="min-h-screen bg-white dark:bg-zinc-800 antialiased"
x-data="nostrLogin"
>
<x-dialog />
<x-notifications />
<script>
if (localStorage.getItem('sidebar-expanded') == 'true') {
document.querySelector('body').classList.add('sidebar-expanded');
} else {
document.querySelector('body').classList.remove('sidebar-expanded');
}
</script>
<div x-data="nostrLogin"
class="flex h-[100dvh] overflow-hidden">
<livewire:layout.sidebar/>
<div
class="relative flex flex-col flex-1 overflow-y-auto overflow-x-hidden">
<!-- Site header -->
<header
class="sticky top-0 before:absolute before:inset-0 before:backdrop-blur-md before:bg-white/90 dark:before:bg-[#222222]/90 lg:before:bg-[#222222]/90 dark:lg:before:bg-[#222222]/90 before:-z-10 max-lg:shadow-sm z-30">
<div class="px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16 lg:border-b border-gray-200 dark:border-gray-700/60">
<flux:header container class="bg-zinc-50 dark:bg-zinc-900 border-b border-zinc-200 dark:border-zinc-700">
<flux:sidebar.toggle class="lg:hidden" icon="bars-2" inset="left" />
<!-- Header: Left side -->
<div class="flex">
<!-- Hamburger button -->
<button
class="text-gray-500 hover:text-gray-600 dark:hover:text-gray-400 lg:hidden"
@click.stop="sidebarOpen = !sidebarOpen"
aria-controls="sidebar"
:aria-expanded="sidebarOpen"
>
<span class="sr-only">Open sidebar</span>
<svg class="w-6 h-6 fill-current" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="5" width="16" height="2"/>
<rect x="4" y="11" width="16" height="2"/>
<rect x="4" y="17" width="16" height="2"/>
</svg>
</button>
<flux:brand href="/" name="Einundzwanzig" class="max-lg:hidden dark:hidden">
<img src="{{ asset('einundzwanzig-alpha.jpg') }}" alt="Logo" class="h-6 w-6">
</flux:brand>
<flux:brand href="/" name="Einundzwanzig" class="max-lg:hidden! hidden dark:flex">
<img src="{{ asset('einundzwanzig-alpha.jpg') }}" alt="Logo" class="h-6 w-6">
</flux:brand>
</div>
<flux:navbar class="-mb-px max-lg:hidden">
@if(\App\Support\NostrAuth::check())
<flux:navbar.item icon="rss" :href="route('association.news')" {{ request()->routeIs('association.news') ? 'current' : '' }}>News</flux:navbar.item>
<flux:navbar.item icon="id-card-clip" :href="route('association.profile')" {{ request()->routeIs('association.profile') ? 'current' : '' }}>Profil</flux:navbar.item>
<flux:navbar.item icon="hand-heart" :href="route('association.projectSupport')" {{ request()->routeIs('association.projectSupport') ? 'current' : '' }}>Projekt-Unterstützungen</flux:navbar.item>
@endif
</flux:navbar>
<!-- Header: Right side -->
<div class="flex items-center space-x-3">
<flux:spacer />
{{--@include('components.layouts.partials.search-button')--}}
<flux:navbar class="me-4">
<flux:dropdown position="bottom" align="end" class="max-lg:hidden">
<flux:navbar.item icon:trailing="information-circle">Info</flux:navbar.item>
{{--@include('components.layouts.partials.notification-buttons')--}}
<flux:menu>
<flux:menu.item href="https://gitworkshop.dev/r/naddr1qvzqqqrhnypzqzklvar4enzu53t06vpzu3h465nwkzhk9p9ls4y5crwhs3lnu5pnqy88wumn8ghj7mn0wvhxcmmv9uqpxetfde6kuer6wasku7nfvukkummnw3eqdgsn8w/issues" target="_blank">Issues/Feedback</flux:menu.item>
<flux:menu.item :href="route('changelog')">Changelog</flux:menu.item>
<flux:menu.item href="https://github.com/HolgerHatGarkeineNode/einundzwanzig-nostr" target="_blank">Github</flux:menu.item>
<flux:menu.item href="https://einundzwanzig.space/kontakt/" target="_blank">Impressum</flux:menu.item>
</flux:menu>
</flux:dropdown>
@if(\App\Support\NostrAuth::check())
<form method="post" action="{{ route('logout') }}" @submit="$dispatch('nostrLoggedOut')">
@csrf
<flux:navbar.item type="submit" icon="arrow-right-start-on-rectangle">Logout</flux:navbar.item>
</form>
@else
<flux:navbar.item icon="user" wire:key="loginBtn" @click="openNostrLogin">Mit Nostr verbinden</flux:navbar.item>
@endif
</flux:navbar>
</flux:header>
@if(\App\Support\NostrAuth::check())
<form method="post" action="{{ route('logout') }}"
@submit="$dispatch('nostrLoggedOut')">
@csrf
<x-button secondary label="Logout" type="submit"/>
</form>
@else
<x-button wire:key="loginBtn" label="Mit Nostr verbinden" @click="openNostrLogin"
x-show="!$store.nostr.user"/>
@endif
<flux:sidebar sticky collapsible="mobile" class="bg-zinc-50 dark:bg-zinc-900 border-r border-zinc-200 dark:border-zinc-700">
<flux:sidebar.header>
<flux:sidebar.brand
href="/"
name="Einundzwanzig"
>
<img src="{{ asset('einundzwanzig-alpha.jpg') }}" alt="Logo" class="h-6 w-6">
</flux:sidebar.brand>
<!-- Info button -->
<div class="relative inline-flex" x-data="{ open: false }">
<button
class="w-8 h-8 flex items-center justify-center hover:bg-[#1B1B1B] lg:hover:bg-[#1B1B1B] dark:hover:bg-[#1B1B1B]/50 dark:lg:hover:bg-[#1B1B1B] rounded-full"
:class="{ 'bg-gray-200 dark:bg-[#1B1B1B]': open }"
aria-haspopup="true"
@click.prevent="open = !open"
:aria-expanded="open"
>
<span class="sr-only">Info</span>
<svg class="fill-current text-gray-500/80 dark:text-gray-400/80" width="16" height="16"
viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path
d="M9 7.5a1 1 0 1 0-2 0v4a1 1 0 1 0 2 0v-4ZM9 4.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"/>
<path fill-rule="evenodd"
d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16Zm6-8A6 6 0 1 1 2 8a6 6 0 0 1 12 0Z"/>
</svg>
</button>
<div
class="origin-top-right z-10 absolute top-full right-0 min-w-44 bg-white dark:bg-[#1B1B1B] border border-gray-200 dark:border-[#1B1B1B]/60 py-1.5 rounded-lg shadow-lg overflow-hidden mt-1"
@click.outside="open = false"
@keydown.escape.window="open = false"
x-show="open"
x-transition:enter="transition ease-out duration-200 transform"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-out duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
x-cloak
>
<div
class="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase pt-1.5 pb-2 px-3">
Information
</div>
<ul>
<li>
<a class="font-medium text-sm text-amber-500 hover:text-amber-600 dark:hover:text-amber-400 flex items-center py-1 px-3"
target="_blank"
href="https://gitworkshop.dev/r/naddr1qvzqqqrhnypzqzklvar4enzu53t06vpzu3h465nwkzhk9p9ls4y5crwhs3lnu5pnqy88wumn8ghj7mn0wvhxcmmv9uqpxetfde6kuer6wasku7nfvukkummnw3eqdgsn8w/issues"
@click="open = false" @focus="open = true"
@focusout="open = false">
<i class="fa-sharp-duotone fa-solid fa-code w-3 h-3 fill-current text-amber-500 shrink-0 mr-2"></i>
<span>Issues/Feedback</span>
</a>
</li>
<li>
<a class="font-medium text-sm text-amber-500 hover:text-amber-600 dark:hover:text-amber-400 flex items-center py-1 px-3"
href="{{ route('changelog') }}" @click="open = false" @focus="open = true"
@focusout="open = false">
<i class="fa-sharp-duotone fa-solid fa-code w-3 h-3 fill-current text-amber-500 shrink-0 mr-2"></i>
<span>Changelog</span>
</a>
</li>
<li>
<a class="font-medium text-sm text-amber-500 hover:text-amber-600 dark:hover:text-amber-400 flex items-center py-1 px-3"
href="https://github.com/HolgerHatGarKeineNode/einundzwanzig-nostr"
target="_blank" @click="open = false" @focus="open = true"
@focusout="open = false">
<i class="fa-brands fa-github w-3 h-3 fill-current text-amber-500 shrink-0 mr-2"></i>
<span>Github</span>
</a>
</li>
<li>
<a class="font-medium text-sm text-amber-500 hover:text-amber-600 dark:hover:text-amber-400 flex items-center py-1 px-3"
href="https://einundzwanzig.space/kontakt/" target="_blank"
@click="open = false" @focus="open = true"
@focusout="open = false">
<i class="fa-sharp-duotone fa-solid fa-info w-3 h-3 fill-current text-amber-500 shrink-0 mr-2"></i>
<span>Impressum</span>
</a>
</li>
</ul>
</div>
</div>
<flux:sidebar.collapse class="in-data-flux-sidebar-on-desktop:not(in-data-flux-sidebar-collapsed-desktop):-mr-2" />
</flux:sidebar.header>
{{--@include('components.layouts.partials.dark-mode-toggle')--}}
<flux:sidebar.nav>
@if(\App\Support\NostrAuth::check())
<flux:sidebar.item icon="rss" :href="route('association.news')" {{ request()->routeIs('association.news') ? 'current' : '' }}>News</flux:sidebar.item>
<flux:sidebar.item icon="id-card-clip" :href="route('association.profile')" {{ request()->routeIs('association.profile') ? 'current' : '' }}>Meine Mitgliedschaft</flux:sidebar.item>
<flux:sidebar.item icon="hand-heart" :href="route('association.projectSupport')" {{ request()->routeIs('association.projectSupport') ? 'current' : '' }}>Projekt-Unterstützungen</flux:sidebar.item>
@endif
<!-- Divider -->
{{--<hr class="w-px h-6 bg-gray-200 dark:bg-gray-700/60 border-none"/>--}}
@include('components.layouts.navigation.admin')
</flux:sidebar.nav>
</flux:sidebar>
{{--@include('components.layouts.partials.user-button')--}}
<flux:main container>
{{ $slot }}
</flux:main>
</div>
</div>
</div>
</header>
<main class="grow">
{{ $slot }}
</main>
</div>
</div>
@livewireScriptConfig
<script>
window.wnjParams = {
position: 'bottom',
// The only accepted value is 'bottom', default is top
accent: 'orange',
// Supported values: cyan (default), green, purple, red, orange, neutral, stone
startHidden: false,
// If the host page has a button that call `getPublicKey` to start a
// login procedure, the minimized widget can be hidden until connected
compactMode: false,
// Show the minimized widget in a compact form
disableOverflowFix: false,
// If the host page on mobile has an horizontal scrolling, the floating
// element/modal are pushed to the extreme right/bottom and exit the
// viewport. A style is injected in the html/body elements fix this.
// This option permit to disable this default behavior
}
</script>
<script src="{{ asset('dist/window.nostr.min.js.js') }}"></script>
@fluxScripts
@livewireScriptConfig
<script>
window.wnjParams = {
position: 'bottom',
accent: 'orange',
startHidden: false,
compactMode: false,
disableOverflowFix: false,
}
</script>
<script src="{{ asset('dist/window.nostr.min.js.js') }}"></script>
</body>
</html>

View File

@@ -78,11 +78,16 @@ new class extends Component {
{{ $election['year'] }}
</div>
<div class="shadow-lg rounded-lg overflow-hidden">
<x-textarea wire:model="elections.{{ $loop->index }}.candidates" rows="25"
label="candidates" placeholder=""/>
<flux:field>
<flux:label>Kandidaten</flux:label>
<flux:textarea wire:model="elections.{{ $loop->index }}.candidates" rows="25" placeholder="Kandidaten..."/>
<flux:error name="elections.{{ $loop->index }}.candidates" />
</flux:field>
</div>
<div class="py-2">
<x-button label="Speichern" wire:click="saveElection({{ $loop->index }})" wire:loading.attr="disabled"/>
<flux:button wire:click="saveElection({{ $loop->index }})" wire:loading.attr="disabled">
Speichern
</flux:button>
</div>
</div>
@endforeach

View File

@@ -475,19 +475,19 @@ new class extends Component {
<div
class="flex items-center justify-between before:absolute before:inset-0 before:backdrop-blur-md before:bg-gray-50/90 dark:before:bg-[#1B1B1B]/90 before:-z-10 border-b border-gray-200 dark:border-gray-700/60 px-4 sm:px-6 md:px-5 h-16">
<div
class="flex flex-col space-y-2 sm:space-y-0 sm:flex-row justify-between items-center w-full">
<div>
@if($isNotClosed)
<x-badge success
label="Die Wahl ist geöffnet bis zum {{ $election->end_time?->timezone('Europe/Berlin')->format('d.m.Y H:i') }}"/>
@else
<x-badge negative label="Die Wahl ist geschlossen"/>
@endif
</div>
<div>
<x-button secondary
:href="route('association.election.admin', ['election' => $election])"
label="Wahl-Admin"/>
class="flex flex-col space-y-2 sm:space-y-0 sm:flex-row justify-between items-center w-full">
<div>
@if($isNotClosed)
<flux:badge color="success" label="Die Wahl ist geöffnet bis zum {{ $election->end_time?->timezone('Europe/Berlin')->format('d.m.Y H:i') }}"/>
@else
<flux:badge color="danger" label="Die Wahl ist geschlossen"/>
@endif
</div>
<div>
<flux:button secondary
:href="route('association.election.admin', ['election' => $election])"
label="Wahl-Admin">
</flux:button>
</div>
</div>
</div>

View File

@@ -202,19 +202,22 @@ class extends Component {
</div>
</div>
<div class="mt-2 flex justify-end w-full space-x-2">
<x-button
<flux:button
xs
target="_blank"
:href="url()->temporarySignedRoute('dl', now()->addMinutes(30), ['media' => $post->getFirstMedia('pdf')])"
label="Öffnen"
primary icon="cloud-arrow-down"/>
icon="cloud-arrow-down">
Öffnen
</flux:button>
@if($canEdit)
<x-button
<flux:button
xs
negative
wire:click="delete({{ $post->id }})"
wire:loading.attr="disabled"
label="Löschen"
negative icon="trash"/>
icon="trash">
Löschen
</flux:button>
@endif
</div>
</article>
@@ -253,27 +256,40 @@ class extends Component {
@enderror
</div>
<div wire:dirty>
<x-native-select
wire:model="form.category"
label="Kategorie"
placeholder="Wähle Kategorie"
:options="\App\Enums\NewsCategory::selectOptions()"
option-label="label" option-value="value"
/>
<flux:field>
<flux:label>Kategorie</flux:label>
<flux:select
wire:model="form.category"
placeholder="Wähle Kategorie"
>
@foreach(\App\Enums\NewsCategory::selectOptions() as $category)
<flux:select.option
:label="$category['label']"
:value="$category['value']"
/>
@endforeach
</flux:select>
<flux:error name="form.category" />
</flux:field>
</div>
<div wire:dirty>
<x-input label="Titel" wire:model="form.name"/>
<flux:field>
<flux:label>Titel</flux:label>
<flux:input wire:model="form.name" placeholder="News-Titel" />
<flux:error name="form.name" />
</flux:field>
</div>
<div wire:dirty>
<x-textarea
description="optional"
label="Beschreibung" wire:model="form.description"/>
<flux:field>
<flux:label>Beschreibung</flux:label>
<flux:description>optional</flux:description>
<flux:textarea wire:model="form.description" rows="4" placeholder="Beschreibung..." />
<flux:error name="form.description" />
</flux:field>
</div>
<button
wire:click="save"
class="btn-sm w-full bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700/60 hover:border-gray-300 dark:hover:border-gray-600 text-gray-800 dark:text-gray-300">
<flux:button wire:click="save" class="w-full">
Hinzufügen
</button>
</flux:button>
</div>
</div>
@endif

View File

@@ -503,19 +503,22 @@ new class extends Component {
sich bitte direkt an den Vorstand.</a>
</h4>
<div class="sm:flex sm:items-center space-y-4 sm:space-y-0 sm:space-x-4 mt-5">
<div class="sm:w-1/2 flex flex-col space-y-2">
<div class="flex items-center space-x-2">
<div wire:dirty>
<x-checkbox wire:model="form.check"
label="Ich stimme den Vereins-Statuten zu"/>
</div>
<div class="sm:w-1/2 flex flex-col space-y-2">
<div class="flex items-center space-x-2">
<div wire:dirty>
<flux:field variant="inline">
<flux:checkbox wire:model="form.check" label="Ich stimme den Vereins-Statuten zu"/>
<flux:error name="form.check" />
</flux:field>
</div>
<div>
<a href="https://einundzwanzig.space/verein/" target="_blank"
class="text-amber-500">Statuten</a>
</div>
</div>
<x-button label="Mit deinem aktuellen Nostr-Profil Mitglied werden"
wire:click="save({{ AssociationStatus::PASSIVE() }})"/>
<flux:button wire:click="save({{ AssociationStatus::PASSIVE() }})">
Mit deinem aktuellen Nostr-Profil Mitglied werden
</flux:button>
</div>
</div>
@endif
@@ -543,25 +546,35 @@ new class extends Component {
diese Adresse AES-256 verschlüsselt in der Datenbank ab.
</div>
<div
class="flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-2 text-amber-500">
<x-toggle xl warning
wire:model.live="no"
wire:dirty
label="NEIN">
<x-slot name="description">
<span class="py-2 text-amber-500">Ich informiere mich selbst in der News Sektion und gebe keine E-Mail Adresse raus.</span>
</x-slot>
</x-toggle>
class="flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-2 text-amber-500">
<flux:field variant="inline" xl>
<flux:label>NEIN</flux:label>
<flux:description>Ich informiere mich selbst in der News Sektion und gebe keine E-Mail Adresse raus.</flux:description>
<flux:switch
wire:model.live="no"
wire:dirty
/>
<flux:error name="no" />
</flux:field>
</div>
@if($showEmail)
<div wire:key="showEmail"
class="flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-2">
<x-input wire:model.live.debounce="fax" wire:dirty label="Fax-Nummer"/>
<x-input wire:model.live.debounce="email" wire:dirty
label="E-Mail Adresse"/>
<flux:field>
<flux:label>Fax-Nummer</flux:label>
<flux:input wire:model.live.debounce="fax" wire:dirty placeholder="Fax-Nummer"/>
<flux:error name="fax" />
</flux:field>
<flux:field>
<flux:label>E-Mail Adresse</flux:label>
<flux:input type="email" wire:model.live.debounce="email" wire:dirty placeholder="E-Mail Adresse"/>
<flux:error name="email" />
</flux:field>
</div>
<div wire:key="showSave" class="flex space-x-2 mt-2">
<x-button wire:click="saveEmail" label="Speichern"/>
<flux:button wire:click="saveEmail" wire:loading.attr="disabled">
Speichern
</flux:button>
</div>
@endif
</div>

View File

@@ -70,23 +70,22 @@ class extends Component {
</h2>
<div class="space-y-4">
<div wire:dirty>
<x-input label="Name" wire:model="form.name"/>
@error('form.name')
<span class="text-red-500">{{ $message }}</span>
@enderror
<flux:field>
<flux:label>Name</flux:label>
<flux:input wire:model="form.name" placeholder="Projektname" />
<flux:error name="form.name" />
</flux:field>
</div>
<div wire:dirty>
<x-textarea label="Beschreibung" wire:model="form.description"/>
@error('form.description')
<span class="text-red-500">{{ $message }}</span>
@enderror
<flux:field>
<flux:label>Beschreibung</flux:label>
<flux:textarea wire:model="form.description" rows="6" placeholder="Projektbeschreibung..." />
<flux:error name="form.description" />
</flux:field>
</div>
<button
wire:click="save"
wire:loading.attr="disabled"
class="w-full btn-sm bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700/60 hover:border-gray-300 dark:hover:border-gray-600 text-gray-800 dark:text-gray-300">
<flux:button wire:click="save" wire:loading.attr="disabled" variant="primary" class="w-full">
Speichern
</button>
</flux:button>
</div>
</div>
</div>

View File

@@ -27,7 +27,13 @@ class extends Component {
$currentPubkey = NostrAuth::pubkey();
$currentPleb = \App\Models\EinundzwanzigPleb::query()->where('pubkey', $currentPubkey)->first();
if ($currentPleb && $currentPleb->id === $project->einundzwanzig_pleb_id) {
if (
(
$currentPleb
&& $currentPleb->id === $project->einundzwanzig_pleb_id
)
|| in_array($currentPleb->npub, config('einundzwanzig.config.current_board'))
) {
$this->isAllowed = true;
$this->form = [
'name' => $project->name,
@@ -76,23 +82,22 @@ class extends Component {
</h2>
<div class="space-y-4">
<div wire:dirty>
<x-input label="Name" wire:model="form.name"/>
@error('form.name')
<span class="text-red-500">{{ $message }}</span>
@enderror
<flux:field>
<flux:label>Name</flux:label>
<flux:input wire:model="form.name" placeholder="Projektname" />
<flux:error name="form.name" />
</flux:field>
</div>
<div wire:dirty>
<x-textarea label="Beschreibung" wire:model="form.description"/>
@error('form.description')
<span class="text-red-500">{{ $message }}</span>
@enderror
<flux:field>
<flux:label>Beschreibung</flux:label>
<flux:textarea wire:model="form.description" rows="6" placeholder="Projektbeschreibung..." />
<flux:error name="form.description" />
</flux:field>
</div>
<button
wire:click="update"
wire:loading.attr="disabled"
class="w-full btn-sm bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700/60 hover:border-gray-300 dark:hover:border-gray-600 text-gray-800 dark:text-gray-300">
<flux:button wire:click="update" wire:loading.attr="disabled" variant="primary" class="w-full">
Speichern
</button>
</flux:button>
</div>
</div>
</div>

View File

@@ -118,14 +118,15 @@ new class extends Component {
<!-- Search form -->
<form class="relative">
<x-input type="search" wire:model.live.debounce="search"
placeholder="Suche"/>
<flux:input type="search" wire:model.live.debounce="search"
placeholder="Suche" icon="magnifying-glass"/>
</form>
<!-- Add meetup button -->
@if($currentPleb && $currentPleb->association_status->value > 1 && $currentPleb->paymentEvents()->where('year', date('Y'))->where('paid', true)->exists())
<x-button :href="route('association.projectSupport.create')" icon="plus"
label="Projekt einreichen"/>
<flux:button :href="route('association.projectSupport.create')" icon="plus" variant="primary">
Projekt einreichen
</flux:button>
@endif
</div>

View File

@@ -0,0 +1,111 @@
<?php
use App\Livewire\Forms\ProjectProposalForm;
it('has correct validation rules for all fields', function () {
$form = new ProjectProposalForm;
// Test name field - required|min:5
$form->name = '';
expect(fn () => $form->validate())->toThrow();
$form->name = 'short'; // Less than 5 characters
expect(fn () => $form->validate())->toThrow();
// Test support_in_sats field - required|numeric|min:21
$form->name = 'Valid Project';
$form->support_in_sats = '';
expect(fn () => $form->validate())->toThrow();
$form->support_in_sats = 'not-numeric';
expect(fn () => $form->validate())->toThrow();
$form->support_in_sats = '20'; // Less than 21
expect(fn () => $form->validate())->toThrow();
// Test description field - required|string|min:5
$form->name = 'Valid Project';
$form->support_in_sats = '21000';
$form->description = '';
expect(fn () => $form->validate())->toThrow();
$form->description = 'short';
expect(fn () => $form->validate())->toThrow();
// Test website field - required|url
$form->name = 'Valid Project';
$form->support_in_sats = '21000';
$form->description = 'Valid description';
$form->website = 'not-a-url';
expect(fn () => $form->validate())->toThrow();
});
it('accepts valid project proposal data', function () {
$form = new ProjectProposalForm;
$form->name = 'Test Project';
$form->support_in_sats = '21000';
$form->description = 'This is a test project description that meets the minimum length requirement.';
$form->website = 'https://example.com';
$form->accepted = true;
$form->sats_paid = 5000;
$result = $form->validate();
expect($result)->toBeArray();
expect($result)->toBeEmpty();
});
it('validates accepted field as boolean', function () {
$form = new ProjectProposalForm;
$form->name = 'Valid Project';
$form->support_in_sats = '21000';
$form->description = 'Valid description';
$form->website = 'https://example.com';
$form->accepted = 'not-boolean';
expect(fn () => $form->validate())->toThrow();
// Test with boolean values
$form->accepted = false;
expect($form->accepted)->toBeBool();
$form->accepted = true;
expect($form->accepted)->toBeBool();
});
it('validates sats_paid as nullable numeric', function () {
$form = new ProjectProposalForm;
$form->name = 'Valid Project';
$form->support_in_sats = '21000';
$form->description = 'Valid description';
$form->website = 'https://example.com';
// Test with null (should be acceptable)
$form->sats_paid = null;
$form->accepted = false;
$result = $form->validate();
expect($result)->toBeArray();
expect($result)->toBeEmpty();
// Test with numeric
$form->sats_paid = 'not-numeric';
expect(fn () => $form->validate())->toThrow();
$form->sats_paid = 10000;
$form->accepted = false;
$result = $form->validate();
expect($result)->toBeArray();
expect($result)->toBeEmpty();
});
it('has correct default values', function () {
$form = new ProjectProposalForm;
expect($form->name)->toBe('');
expect($form->support_in_sats)->toBe('');
expect($form->description)->toBe('');
expect($form->website)->toBe('');
expect($form->accepted)->toBeFalse();
expect($form->sats_paid)->toBe(0);
});

View File

@@ -0,0 +1,98 @@
<?php
use App\Enums\AssociationStatus;
use App\Models\EinundzwanzigPleb;
use App\Models\ProjectProposal;
use Illuminate\Support\Str;
use Livewire\Livewire;
beforeEach(function () {
$this->pleb = EinundzwanzigPleb::query()->create([
'pubkey' => 'test_pubkey_'.Str::random(20),
'npub' => 'test_npub_'.Str::random(20),
'association_status' => AssociationStatus::ACTIVE->value,
]);
// Create payment event for the current year
$this->pleb->paymentEvents()->create([
'year' => date('Y'),
'amount' => 21000,
'paid' => true,
'event_id' => 'test_event_'.Str::random(40),
]);
});
it('renders create form for authorized users', function () {
Livewire::actingAs($this->pleb)
->test('association.project-support.form.create')
->assertStatus(200)
->assertSee('Projektförderung anlegen')
->assertSeeLivewire('association.project-support.form.create');
});
it('does not render create form for unauthorized users', function () {
$unauthorizedPleb = EinundzwanzigPleb::query()->create([
'pubkey' => 'test_pubkey_'.Str::random(20),
'npub' => 'test_npub_'.Str::random(20),
'association_status' => AssociationStatus::DEFAULT->value,
]);
Livewire::actingAs($unauthorizedPleb)
->test('association.project-support.form.create')
->assertSet('isAllowed', false)
->assertDontSee('Projektförderung anlegen');
});
it('validates required name field', function () {
Livewire::actingAs($this->pleb)
->test('association.project-support.form.create')
->set('form.name', '')
->set('form.description', 'Test description')
->call('save')
->assertHasErrors(['form.name']);
});
it('validates name max length', function () {
Livewire::actingAs($this->pleb)
->test('association.project-support.form.create')
->set('form.name', Str::random(300))
->set('form.description', 'Test description')
->call('save')
->assertHasErrors(['form.name']);
});
it('validates required description field', function () {
Livewire::actingAs($this->pleb)
->test('association.project-support.form.create')
->set('form.name', 'Test Project')
->set('form.description', '')
->call('save')
->assertHasErrors(['form.description']);
});
it('creates project proposal successfully', function () {
Livewire::actingAs($this->pleb)
->test('association.project-support.form.create')
->set('form.name', 'Test Project')
->set('form.description', 'This is a test project for unit testing purposes.')
->call('save')
->assertHasNoErrors()
->assertRedirect(route('association.projectSupport'));
expect(ProjectProposal::count())->toBe(1);
$project = ProjectProposal::first();
expect($project->name)->toBe('Test Project');
expect($project->description)->toBe('This is a test project for unit testing purposes.');
});
it('associates project proposal with current pleb', function () {
Livewire::actingAs($this->pleb)
->test('association.project-support.form.create')
->set('form.name', 'Test Project')
->set('form.description', 'Test description')
->call('save')
->assertHasNoErrors();
$project = ProjectProposal::first();
expect($project->einundzwanzig_pleb_id)->toBe($this->pleb->id);
});

View File

@@ -0,0 +1,109 @@
<?php
use App\Enums\AssociationStatus;
use App\Models\EinundzwanzigPleb;
use App\Models\ProjectProposal;
use Illuminate\Support\Str;
use Livewire\Livewire;
beforeEach(function () {
$this->pleb = EinundzwanzigPleb::query()->create([
'pubkey' => 'test_pubkey_'.Str::random(20),
'npub' => 'test_npub_'.Str::random(20),
'association_status' => AssociationStatus::ACTIVE->value,
]);
// Create payment event for the current year
$this->pleb->paymentEvents()->create([
'year' => date('Y'),
'amount' => 21000,
'paid' => true,
'event_id' => 'test_event_'.Str::random(40),
]);
$this->project = ProjectProposal::query()->create([
'einundzwanzig_pleb_id' => $this->pleb->id,
'name' => 'Original Project',
'description' => 'Original Description',
]);
// Get board member pubkeys from config
$boardPubkeys = config('einundzwanzig.config.current_board', []);
$this->boardMember = EinundzwanzigPleb::query()->create([
'pubkey' => 'board_pubkey_'.Str::random(20),
'npub' => 'board_npub_'.Str::random(20),
'association_status' => AssociationStatus::HONORARY->value,
]);
// Simulate board member by temporarily updating config for testing
config(['einundzwanzig.config.current_board' => [$this->boardMember->npub]]);
});
it('renders edit form for authorized project owners', function () {
Livewire::actingAs($this->pleb)
->test('association.project-support.form.edit', ['project' => $this->project])
->assertStatus(200)
->assertSee('Projektförderung bearbeiten')
->assertSet('form.name', $this->project->name)
->assertSet('form.description', $this->project->description);
});
it('renders edit form for board members', function () {
Livewire::actingAs($this->boardMember)
->test('association.project-support.form.edit', ['project' => $this->project])
->assertStatus(200)
->assertSee('Projektförderung bearbeiten');
});
it('does not render edit form for unauthorized users', function () {
$unauthorizedPleb = EinundzwanzigPleb::query()->create([
'pubkey' => 'test_pubkey_'.Str::random(20),
'npub' => 'test_npub_'.Str::random(20),
'association_status' => AssociationStatus::ACTIVE->value,
]);
Livewire::actingAs($unauthorizedPleb)
->test('association.project-support.form.edit', ['project' => $this->project])
->assertSet('isAllowed', false);
});
it('validates required name field', function () {
Livewire::actingAs($this->pleb)
->test('association.project-support.form.edit', ['project' => $this->project])
->set('form.name', '')
->set('form.description', 'Test description')
->call('update')
->assertHasErrors(['form.name']);
});
it('validates required description field', function () {
Livewire::actingAs($this->pleb)
->test('association.project-support.form.edit', ['project' => $this->project])
->set('form.name', 'Test Project')
->set('form.description', '')
->call('update')
->assertHasErrors(['form.description']);
});
it('updates project proposal successfully', function () {
Livewire::actingAs($this->pleb)
->test('association.project-support.form.edit', ['project' => $this->project])
->set('form.name', 'Updated Name')
->set('form.description', 'Updated Description')
->call('update')
->assertHasNoErrors()
->assertRedirect(route('association.projectSupport.item', $this->project));
$this->project->refresh();
expect($this->project->name)->toBe('Updated Name');
expect($this->project->description)->toBe('Updated Description');
});
it('disables update button during save', function () {
Livewire::actingAs($this->pleb)
->test('association.project-support.form.edit', ['project' => $this->project])
->set('form.name', 'Test')
->set('form.description', 'Test')
->call('update')
->assertSeeHtml('wire:loading');
});