Add activity index filter
continuous-integration/drone/push Build is failing Details

This commit is contained in:
philipp lang 2023-02-23 22:43:13 +01:00
parent a18214b803
commit 067cbe6d9d
12 changed files with 400 additions and 6 deletions

View File

@ -2,8 +2,10 @@
namespace App;
use App\Http\Views\ActivityFilterScope;
use App\Nami\HasNamiField;
use Cviebrock\EloquentSluggable\Sluggable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
@ -30,6 +32,16 @@ class Activity extends Model
];
}
/**
* @param Builder<self> $query
*
* @return Builder<self>
*/
public function scopeWithFilter(Builder $query, ActivityFilterScope $filter): Builder
{
return $filter->apply($query);
}
/**
* @return BelongsToMany<Subactivity>
*/
@ -37,4 +49,5 @@ class Activity extends Model
{
return $this->belongsToMany(Subactivity::class);
}
}

View File

@ -4,6 +4,7 @@ namespace App\Activity\Actions;
use App\Activity;
use App\Activity\Resources\ActivityResource;
use App\Http\Views\ActivityFilterScope;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Inertia\Inertia;
use Inertia\Response;
@ -14,15 +15,17 @@ class IndexAction
{
use AsAction;
public function handle(): AnonymousResourceCollection
public function handle(ActivityFilterScope $filter): AnonymousResourceCollection
{
return ActivityResource::collection(Activity::local()->paginate(20));
return ActivityResource::collection(Activity::local()->withFilter($filter)->paginate(20));
}
public function asController(ActionRequest $request): Response
{
return Inertia::render('activity/Index', [
'data' => $this->handle(),
$filter = ActivityFilterScope::fromRequest($request->input('filter'));
return Inertia::render('activity/VIndex', [
'data' => $this->handle($filter),
]);
}
}

View File

@ -3,6 +3,9 @@
namespace App\Activity\Resources;
use App\Activity;
use App\Http\Views\ActivityFilterScope;
use App\Lib\HasMeta;
use App\Subactivity;
use Illuminate\Http\Resources\Json\JsonResource;
/**
@ -10,6 +13,8 @@ use Illuminate\Http\Resources\Json\JsonResource;
*/
class ActivityResource extends JsonResource
{
use HasMeta;
/**
* Transform the resource into an array.
*
@ -24,4 +29,15 @@ class ActivityResource extends JsonResource
'id' => $this->id,
];
}
/**
* @return array<string, mixed>
*/
public static function meta(): array
{
return [
'subactivities' => Subactivity::pluck('name', 'id'),
'filter' => ActivityFilterScope::fromRequest(request()->input('filter')),
];
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Http\Views;
use App\Activity;
use Illuminate\Database\Eloquent\Builder;
use Spatie\LaravelData\Attributes\MapInputName;
use Spatie\LaravelData\Attributes\MapOutputName;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
/**
* @extends Filter<Activity>
*/
#[MapInputName(SnakeCaseMapper::class)]
#[MapOutputName(SnakeCaseMapper::class)]
class ActivityFilterScope extends Filter
{
public function __construct(
public ?int $subactivityId = null,
) {
}
/**
* {@inheritdoc}
*/
public function locks(): array
{
return [];
}
/**
* @param Builder<Activity> $query
*
* @return Builder<Activity>
*/
public function apply(Builder $query): Builder
{
if ($this->subactivityId) {
$query->whereHas('subactivities', fn ($query) => $query->where('id', $this->subactivityId));
}
return $query;
}
}

72
app/Http/Views/Filter.php Normal file
View File

@ -0,0 +1,72 @@
<?php
namespace App\Http\Views;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Spatie\LaravelData\Data;
/**
* @template T of Model
*/
abstract class Filter extends Data
{
public string $unsetReplacer = 'yoNee3ainge4eetiier9ogaiChoe0ahcaR3Hu1uzah8xaiv7ael7yahphai7ruG9';
/**
* @return array<string, mixed>
*/
abstract protected function locks(): array;
public static function fromRequest(?string $request = null): static
{
$parameters = json_decode(base64_decode($request), true);
return static::from($parameters ?: [])->parseLocks();
}
public function parseLocks(): static
{
foreach ($this->locks() as $key => $value) {
if ($value === $this->unsetReplacer) {
continue;
}
$this->{$key} = $value;
}
return $this;
}
/**
* @param mixed $value
*
* @return mixed
*/
public function when(bool $when, $value)
{
return $when ? $value : $this->unsetReplacer;
}
/**
* @param Builder<T> $query
*
* @return Builder<T>
*/
protected function applyOwnOthers(Builder $query, bool $own, bool $others): Builder
{
if ($own && !$others) {
$query->where('user_id', auth()->id());
}
if (!$own && $others) {
$query->where('user_id', '!=', auth()->id());
}
if (!$own && !$others) {
$query->where('id', -1);
}
return $query;
}
}

33
app/Lib/HasMeta.php Normal file
View File

@ -0,0 +1,33 @@
<?php
namespace App\Lib;
/** @mixin \Illuminate\Http\Resources\Json\JsonResource */
trait HasMeta
{
/**
* Create a new anonymous resource collection.
*
* @param mixed $resource
*
* @return \Illuminate\Http\Resources\Json\AnonymousResourceCollection
*/
public static function collection($resource)
{
$meta = self::meta();
if (!count($meta)) {
return parent::collection($resource);
}
return parent::collection($resource)->additional([
'meta' => $meta,
]);
}
public static function meta(): array
{
return [];
}
}

View File

@ -0,0 +1 @@
<svg height="512" viewBox="0 0 60 60" width="512" xmlns="http://www.w3.org/2000/svg"><circle cx="34.131" cy="13.505" transform="rotate(-45 34.131 13.513)" r="4.916"/><path d="m17.885 50.17 3.614-.772a10.307 10.307 0 0 0 7.305-6.012l.865-2.03 2.587.883a5.744 5.744 0 0 1 3.682 3.845l.968 3.289a2.811 2.811 0 0 0 2.707 2.038c.11 0 .222-.008.342-.026a2.821 2.821 0 0 0 2.398-3.528l-1.053-4a11.993 11.993 0 0 0-4.291-6.44l-3.092-2.364 1.182-6.535a8.532 8.532 0 0 0 8.933.043l.548-.325a2.351 2.351 0 0 0 .796-3.22 2.358 2.358 0 0 0-3.057-.891 3.403 3.403 0 0 1-4.154-.925l-.488-.617a6.864 6.864 0 0 0-4.376-2.526l-3.709-.549a6.871 6.871 0 0 0-3.794.514l-.6.257c-4.436 1.962-7.22 6.526-6.92 11.365.035.617.334 1.19.814 1.585.48.385 1.096.565 1.713.48a2.234 2.234 0 0 0 1.91-2.125 6.573 6.573 0 0 1 2.997-5.267l-3.288 15.725-6.244 2.449a2.951 2.951 0 0 0-1.893 2.766c0 .9.403 1.748 1.105 2.313.703.565 1.61.788 2.493.6z"/></svg>

After

Width:  |  Height:  |  Size: 921 B

View File

@ -17,6 +17,7 @@
>Beiträge</v-link
>
<v-link href="/contribution" menu="contribution" icon="contribution">Zuschüsse</v-link>
<v-link href="/activity" menu="activity" icon="activity">Tätigkeiten</v-link>
</div>
<div class="grid gap-2">
<v-link href="/setting" menu="setting" icon="setting">Einstellungen</v-link>

View File

@ -0,0 +1,157 @@
<template>
<div class="pb-6">
<member-filter
:value="query.filter"
:activities="filterActivities"
:subactivities="filterSubactivities"
:bill-kinds="billKinds"
></member-filter>
<table cellspacing="0" cellpadding="0" border="0" class="custom-table custom-table-sm hidden md:table">
<thead>
<th></th>
<th>Nachname</th>
<th>Vorname</th>
<th class="hidden 2xl:table-cell">Ort</th>
<th>Tags</th>
<th class="hidden xl:table-cell">Alter</th>
<th class="hidden xl:table-cell" v-show="hasModule('bill')">Rechnung</th>
<th v-show="hasModule('bill')">Ausstand</th>
<th></th>
</thead>
<tr v-for="(member, index) in data.data" :key="index">
<td><age-groups :member="member"></age-groups></td>
<td v-text="member.lastname"></td>
<td v-text="member.firstname"></td>
<td class="hidden 2xl:table-cell" v-text="member.full_address"></td>
<td><tags :member="member"></tags></td>
<td class="hidden xl:table-cell" v-text="member.age"></td>
<td class="hidden xl:table-cell" v-show="hasModule('bill')">
<v-label :value="member.bill_kind_name" fallback="kein"></v-label>
</td>
<td v-show="hasModule('bill')">
<v-label :value="member.pending_payment" fallback="---"></v-label>
</td>
<td>
<actions :member="member" @sidebar="openSidebar(index, $event)" @remove="remove(member)"></actions>
</td>
</tr>
</table>
<div class="md:hidden p-3 grid gap-3">
<box class="relative" :heading="member.fullname" v-for="(member, index) in data.data" :key="index">
<div slot="in-title">
<age-groups class="ml-2" :member="member" icon-class="w-4 h-4"></age-groups>
</div>
<div class="text-xs text-gray-200" v-text="member.full_address"></div>
<div class="flex items-center mt-1 space-x-4">
<tags :member="member"></tags>
<v-label
class="text-gray-100 block"
v-show="hasModule('bill')"
:value="member.pending_payment"
fallback=""
></v-label>
</div>
<actions
class="mt-2"
:member="member"
@sidebar="openSidebar(index, $event)"
@remove="remove(member)"
></actions>
<div class="absolute right-0 top-0 h-full flex items-center mr-2">
<i-link :href="member.links.show" v-tooltip="`Details`"
><svg-sprite src="chevron-down" class="w-6 h-6 text-teal-100 -rotate-90"></svg-sprite
></i-link>
</div>
</box>
</div>
<div class="px-6">
<v-pages class="mt-4" :value="data.meta" :only="['data']"></v-pages>
</div>
<transition name="sidebar">
<member-payments
v-if="single !== null && sidebar === 'payment.index'"
@close="closeSidebar"
:subscriptions="subscriptions"
:statuses="statuses"
:value="data.data[single]"
></member-payments>
<member-memberships
v-if="single !== null && sidebar === 'membership.index'"
@close="closeSidebar"
:activities="activities"
:subactivities="subactivities"
:value="data.data[single]"
></member-memberships>
<member-courses
v-if="single !== null && sidebar === 'courses.index'"
@close="closeSidebar"
:courses="courses"
:value="data.data[single]"
></member-courses>
</transition>
</div>
</template>
<script>
import MemberPayments from './MemberPayments.vue';
import MemberMemberships from './MemberMemberships.vue';
import MemberCourses from './MemberCourses.vue';
import MemberFilter from './MemberFilter.vue';
import mergesQueryString from '../../mixins/mergesQueryString.js';
export default {
data: function () {
return {
sidebar: null,
single: null,
};
},
mixins: [mergesQueryString],
components: {
MemberMemberships,
MemberPayments,
MemberFilter,
MemberCourses,
'age-groups': () => import(/* webpackChunkName: "member" */ './AgeGroups'),
'tags': () => import(/* webpackChunkName: "member" */ './Tags'),
'actions': () => import(/* webpackChunkName: "member" */ './index/Actions'),
},
methods: {
remove(member) {
if (window.confirm('Mitglied löschen?')) {
this.$inertia.delete(`/member/${member.id}`);
}
},
openSidebar(index, name) {
this.single = index;
this.sidebar = name;
},
closeSidebar() {
this.single = null;
this.sidebar = null;
},
},
props: {
data: {},
subscriptions: {},
statuses: {},
paymentDefaults: {},
query: {},
billKinds: {},
activities: {},
subactivities: {},
filterActivities: {},
filterSubactivities: {},
courses: {},
},
};
</script>

View File

@ -3,6 +3,7 @@
namespace Tests\Feature\Activity;
use App\Activity;
use App\Subactivity;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;
@ -13,12 +14,34 @@ class IndexTest extends TestCase
public function testItDisplaysLocalActivities(): void
{
$this->login()->loginNami()->withoutExceptionHandling();
$local = Activity::factory()->name('Local')->create();
$remote = Activity::factory()->name('Remote')->inNami(123)->create();
Activity::factory()->name('Local')->create();
Activity::factory()->name('Remote')->inNami(123)->create();
$response = $this->get('/activity');
$this->assertInertiaHas('Local', $response, 'data.data.0.name');
$this->assertCount(1, $this->inertia($response, 'data.data'));
}
public function testItDisplaysDefaultFilter(): void
{
$this->login()->loginNami()->withoutExceptionHandling();
$response = $this->callFilter('activity.index', []);
$this->assertInertiaHas(null, $response, 'data.meta.filter.subactivity');
}
public function testItFiltersActivityBySubactivity(): void
{
$this->login()->loginNami()->withoutExceptionHandling();
$subactivity = Subactivity::factory()->name('jjon')->create();
Activity::factory()->name('Local')->hasAttached($subactivity)->create();
Activity::factory()->count(2)->name('Local')->create();
$response = $this->callFilter('activity.index', ['subactivity_id' => $subactivity->id]);
$this->assertInertiaHas($subactivity->id, $response, 'data.meta.filter.subactivity_id');
$this->assertCount(1, $this->inertia($response, 'data.data'));
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace Tests\Lib;
use Illuminate\Testing\TestResponse;
trait MakesHttpCalls
{
/**
* @param array<string, mixed> $filter
*/
public function callFilter(string $routeName, array $filter): TestResponse
{
return $this->call('GET', $this->filterUrl($routeName, $filter));
}
/**
* @param array<string, mixed> $filter
*/
public function filterUrl(string $routeName, array $filter): string
{
$params = [
'filter' => base64_encode(json_encode($filter)),
];
return route($routeName, $params);
}
}

View File

@ -11,6 +11,7 @@ use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Http;
use Illuminate\Testing\TestResponse;
use Phake;
use Tests\Lib\MakesHttpCalls;
use Tests\Lib\TestsInertia;
use Zoomyboy\LaravelNami\Authentication\Auth;
@ -18,6 +19,7 @@ abstract class TestCase extends BaseTestCase
{
use CreatesApplication;
use TestsInertia;
use MakesHttpCalls;
protected User $me;