Add Broadcast event when deleting member
continuous-integration/drone/push Build is passing Details

This commit is contained in:
philipp lang 2023-08-16 00:43:28 +02:00
parent 3263e93da7
commit aeb926e165
16 changed files with 368 additions and 210 deletions

View File

@ -0,0 +1,50 @@
<?php
namespace App\Lib\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class JobEvent implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public bool $reload = false;
public string $message = '';
final private function __construct(public string $channel)
{
}
public static function on(string $channel): static
{
return new static($channel);
}
public function withMessage(string $message): static
{
$this->message = $message;
return $this;
}
/**
* Get the channels the event should broadcast on.
*
* @return \Illuminate\Broadcasting\Channel
*/
public function broadcastOn()
{
return new Channel($this->channel);
}
public function shouldReload(): static
{
$this->reload = true;
return $this;
}
}

View File

@ -0,0 +1,7 @@
<?php
namespace App\Lib\Events;
class JobFinished extends JobEvent
{
}

View File

@ -0,0 +1,7 @@
<?php
namespace App\Lib\Events;
class JobStarted extends JobEvent
{
}

View File

@ -0,0 +1,52 @@
<?php
namespace App\Lib\JobMiddleware;
use App\Lib\Events\JobFinished;
use App\Lib\Events\JobStarted;
use Closure;
use Lorisleiva\Actions\Decorators\JobDecorator;
class WithJobState
{
public JobStarted $beforeMessage;
public JobFinished $afterMessage;
private function __construct(public string $channel)
{
}
public static function make(string $channel): self
{
return new self($channel);
}
public function before(string $message): self
{
$this->beforeMessage = JobStarted::on($this->channel)->withMessage($message);
return $this;
}
public function after(string $message): self
{
$this->afterMessage = JobFinished::on($this->channel)->withMessage($message);
return $this;
}
public function shouldReload(): self
{
$this->afterMessage->shouldReload();
return $this;
}
public function handle(JobDecorator $job, Closure $next): void
{
event($this->beforeMessage);
$next($job);
event($this->afterMessage);
}
}

View File

@ -2,7 +2,7 @@
namespace App\Member\Actions; namespace App\Member\Actions;
use App\Lib\Events\ClientMessage; use App\Lib\JobMiddleware\WithJobState;
use App\Maildispatcher\Actions\ResyncAction; use App\Maildispatcher\Actions\ResyncAction;
use App\Member\Member; use App\Member\Member;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
@ -13,16 +13,37 @@ class MemberDeleteAction
use AsAction; use AsAction;
public function handle(Member $member): RedirectResponse public function handle(int $memberId): void
{ {
$member = Member::findOrFail($memberId);
if ($member->nami_id) { if ($member->nami_id) {
NamiDeleteMemberAction::dispatch($member->nami_id); NamiDeleteMemberAction::run($member->nami_id);
} }
$member->delete(); $member->delete();
ResyncAction::dispatch(); ResyncAction::run();
ClientMessage::make('Mitglied ' . $member->fullname . ' gelöscht.')->shouldReload()->dispatch(); }
public function asController(Member $member): RedirectResponse
{
static::dispatch($member->id);
return redirect()->back(); return redirect()->back();
} }
/**
* @return array<int, object>
*/
public function getJobMiddleware(int $memberId): array
{
$member = Member::findOrFail($memberId);
return [
WithJobState::make('member')
->before('Lösche Mitglied ' . $member->fullname)
->after('Mitglied ' . $member->fullname . ' gelöscht')
->shouldReload(),
];
}
} }

View File

@ -60,6 +60,7 @@
"mockery/mockery": "^1.4.4", "mockery/mockery": "^1.4.4",
"nunomaduro/larastan": "^2.0", "nunomaduro/larastan": "^2.0",
"orchestra/testbench": "^7.0", "orchestra/testbench": "^7.0",
"phpstan/phpstan-mockery": "^1.1",
"phpunit/phpunit": "^9.5.10" "phpunit/phpunit": "^9.5.10"
}, },
"config": { "config": {

54
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "62586d4169459f71189b880d8a86e1ec", "content-hash": "9b301aa44118f3ff66ba16b8688726a3",
"packages": [ "packages": [
{ {
"name": "beyondcode/laravel-dump-server", "name": "beyondcode/laravel-dump-server",
@ -10700,7 +10700,7 @@
"dist": { "dist": {
"type": "path", "type": "path",
"url": "./packages/tex", "url": "./packages/tex",
"reference": "48251272de62e3fea044a7ad31e1a411c15eb4c6" "reference": "6f162102ef7ceca41822d18c3e694abd926f550b"
}, },
"type": "library", "type": "library",
"extra": { "extra": {
@ -11618,6 +11618,56 @@
], ],
"time": "2023-08-08T12:33:42+00:00" "time": "2023-08-08T12:33:42+00:00"
}, },
{
"name": "phpstan/phpstan-mockery",
"version": "1.1.1",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan-mockery.git",
"reference": "6aa86bd8e9c9a1be97baf0558d4a2ed1374736a6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan-mockery/zipball/6aa86bd8e9c9a1be97baf0558d4a2ed1374736a6",
"reference": "6aa86bd8e9c9a1be97baf0558d4a2ed1374736a6",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0",
"phpstan/phpstan": "^1.10"
},
"require-dev": {
"mockery/mockery": "^1.2.4",
"nikic/php-parser": "^4.13.0",
"php-parallel-lint/php-parallel-lint": "^1.2",
"phpstan/phpstan-phpunit": "^1.0",
"phpstan/phpstan-strict-rules": "^1.0",
"phpunit/phpunit": "^9.5"
},
"type": "phpstan-extension",
"extra": {
"phpstan": {
"includes": [
"extension.neon"
]
}
},
"autoload": {
"psr-4": {
"PHPStan\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "PHPStan Mockery extension",
"support": {
"issues": "https://github.com/phpstan/phpstan-mockery/issues",
"source": "https://github.com/phpstan/phpstan-mockery/tree/1.1.1"
},
"time": "2023-02-18T13:54:03+00:00"
},
{ {
"name": "phpunit/php-code-coverage", "name": "phpunit/php-code-coverage",
"version": "9.2.27", "version": "9.2.27",

View File

@ -1,5 +1,6 @@
includes: includes:
- ./vendor/nunomaduro/larastan/extension.neon - ./vendor/nunomaduro/larastan/extension.neon
- ./vendor/phpstan/phpstan-mockery/extension.neon
parameters: parameters:
@ -558,16 +559,6 @@ parameters:
count: 1 count: 1
path: packages/laravel-nami/tests/TestCase.php path: packages/laravel-nami/tests/TestCase.php
-
message: "#^Call to an undefined method Mockery\\\\ExpectationInterface\\|Mockery\\\\HigherOrderMessage\\:\\:never\\(\\)\\.$#"
count: 1
path: tests/Feature/Initialize/InitializeActionTest.php
-
message: "#^Call to an undefined method Mockery\\\\ExpectationInterface\\|Mockery\\\\HigherOrderMessage\\:\\:once\\(\\)\\.$#"
count: 2
path: tests/Feature/Initialize/InitializeMembersTest.php
- -
message: "#^Parameter \\#1 \\$mock of static method Phake\\:\\:verify\\(\\) expects Phake\\\\IMock, App\\\\Actions\\\\PullMemberAction given\\.$#" message: "#^Parameter \\#1 \\$mock of static method Phake\\:\\:verify\\(\\) expects Phake\\\\IMock, App\\\\Actions\\\\PullMemberAction given\\.$#"
count: 1 count: 1

View File

@ -1,9 +1,9 @@
import {ref, computed} from 'vue'; import {ref, computed, onBeforeUnmount} from 'vue';
import {router} from '@inertiajs/vue3'; import {router} from '@inertiajs/vue3';
import Toast, {useToast} from 'vue-toastification'; import {useToast} from 'vue-toastification';
const toast = useToast(); const toast = useToast();
export function useIndex(props) { export function useIndex(props, siteName) {
const rawProps = JSON.parse(JSON.stringify(props)); const rawProps = JSON.parse(JSON.stringify(props));
const inner = { const inner = {
data: ref(rawProps.data), data: ref(rawProps.data),
@ -61,14 +61,21 @@ export function useIndex(props) {
}; };
} }
window.Echo.channel('jobs').listen('\\App\\Lib\\Events\\ClientMessage', (e) => { function handleJobEvent(event) {
if (e.message) { if (event.message) {
toast.success(e.message); toast.success(event.message);
} }
if (e.reload) { if (event.reload) {
reload(false); reload(false);
} }
}); }
window.Echo.channel('jobs').listen('\\App\\Lib\\Events\\ClientMessage', (e) => handleJobEvent(e));
window.Echo.channel(siteName)
.listen('\\App\\Lib\\Events\\JobStarted', (e) => handleJobEvent(e))
.listen('\\App\\Lib\\Events\\JobFinished', (e) => handleJobEvent(e));
onBeforeUnmount(() => window.Echo.leave(siteName));
onBeforeUnmount(() => window.Echo.leave('jobs'));
return { return {
data: inner.data, data: inner.data,

View File

@ -1,7 +1,8 @@
<template> <template>
<page-layout page-class="pb-6"> <page-layout page-class="pb-6">
<template #toolbar> <template #toolbar>
<page-toolbar-button :href="meta.links.create" color="primary" icon="plus">Tätigkeit erstellen</page-toolbar-button> <page-toolbar-button :href="meta.links.create" color="primary" icon="plus">Tätigkeit
erstellen</page-toolbar-button>
</template> </template>
<ui-popup v-if="deleting !== null" heading="Bitte bestätigen" @close="deleting = null"> <ui-popup v-if="deleting !== null" heading="Bitte bestätigen" @close="deleting = null">
<div> <div>
@ -22,8 +23,10 @@
<td v-text="activity.name"></td> <td v-text="activity.name"></td>
<td> <td>
<div class="flex space-x-1"> <div class="flex space-x-1">
<i-link v-tooltip="`bearbeiten`" :href="activity.links.edit" class="inline-flex btn btn-warning btn-sm"><ui-sprite src="pencil"></ui-sprite></i-link> <i-link v-tooltip="`bearbeiten`" :href="activity.links.edit"
<a v-tooltip="`Entfernen`" href="#" class="inline-flex btn btn-danger btn-sm" @click.prevent="deleting = activity"><ui-sprite src="trash"></ui-sprite></a> class="inline-flex btn btn-warning btn-sm"><ui-sprite src="pencil"></ui-sprite></i-link>
<a v-tooltip="`Entfernen`" href="#" class="inline-flex btn btn-danger btn-sm"
@click.prevent="deleting = activity"><ui-sprite src="trash"></ui-sprite></a>
</div> </div>
</td> </td>
</tr> </tr>
@ -36,11 +39,11 @@
</template> </template>
<script setup> <script setup>
import {ref, defineProps} from 'vue'; import { ref, defineProps } from 'vue';
import {indexProps, useIndex} from '../../composables/useIndex.js'; import { indexProps, useIndex } from '../../composables/useIndex.js';
const props = defineProps(indexProps); const props = defineProps(indexProps);
const {router, data, meta} = useIndex(props.data); const { router, data, meta } = useIndex(props.data, 'activity');
const deleting = ref(null); const deleting = ref(null);
function remove() { function remove() {

View File

@ -10,56 +10,27 @@
<ui-box heading="Metadatem"> <ui-box heading="Metadatem">
<div class="grid gap-4 sm:grid-cols-2"> <div class="grid gap-4 sm:grid-cols-2">
<f-text id="name" v-model="model.name" name="name" label="Name" size="sm" required></f-text> <f-text id="name" v-model="model.name" name="name" label="Name" size="sm" required></f-text>
<f-select id="gateway_id" v-model="model.gateway_id" name="gateway_id" :options="meta.gateways" label="Verbindung" size="sm" required></f-select> <f-select id="gateway_id" v-model="model.gateway_id" name="gateway_id" :options="meta.gateways"
label="Verbindung" size="sm" required></f-select>
</div> </div>
</ui-box> </ui-box>
<ui-box v-if="members !== null" heading="Filterregeln"> <ui-box v-if="members !== null" heading="Filterregeln">
<div class="grid gap-4 sm:grid-cols-2"> <div class="grid gap-4 sm:grid-cols-2">
<f-multipleselect <f-multipleselect id="activity_ids" v-model="model.filter.activity_ids" name="activity_ids"
id="activity_ids" :options="members.meta.filterActivities" label="Tätigkeit" size="sm"
v-model="model.filter.activity_ids" @update:model-value="reload(1)"></f-multipleselect>
name="activity_ids" <f-multipleselect id="subactivity_ids" v-model="model.filter.subactivity_ids" name="subactivity_ids"
:options="members.meta.filterActivities" :options="members.meta.filterSubactivities" label="Unterttätigkeit" size="sm"
label="Tätigkeit" @update:model-value="reload(1)"></f-multipleselect>
size="sm" <f-multipleselect id="include" v-model="model.filter.include" name="include"
@update:model-value="reload(1)" :options="members.meta.members" label="Zusätzliche Mitglieder" size="sm"
></f-multipleselect> @update:model-value="reload(1)"></f-multipleselect>
<f-multipleselect <f-multipleselect id="exclude" v-model="model.filter.exclude" name="exclude"
id="subactivity_ids" :options="members.meta.members" label="Mitglieder ausschließen" size="sm"
v-model="model.filter.subactivity_ids" @update:model-value="reload(1)"></f-multipleselect>
name="subactivity_ids" <f-multipleselect id="groupIds" v-model="model.filter.group_ids" name="groupIds"
:options="members.meta.filterSubactivities" :options="members.meta.groups" label="Gruppierungen" size="sm"
label="Unterttätigkeit" @update:model-value="reload(1)"></f-multipleselect>
size="sm"
@update:model-value="reload(1)"
></f-multipleselect>
<f-multipleselect
id="include"
v-model="model.filter.include"
name="include"
:options="members.meta.members"
label="Zusätzliche Mitglieder"
size="sm"
@update:model-value="reload(1)"
></f-multipleselect>
<f-multipleselect
id="exclude"
v-model="model.filter.exclude"
name="exclude"
:options="members.meta.members"
label="Mitglieder ausschließen"
size="sm"
@update:model-value="reload(1)"
></f-multipleselect>
<f-multipleselect
id="groupIds"
v-model="model.filter.group_ids"
name="groupIds"
:options="members.meta.groups"
label="Gruppierungen"
size="sm"
@update:model-value="reload(1)"
></f-multipleselect>
</div> </div>
</ui-box> </ui-box>
<ui-box v-if="members !== null" heading="Mitglieder"> <ui-box v-if="members !== null" heading="Mitglieder">
@ -87,8 +58,8 @@
</template> </template>
<script setup> <script setup>
import {ref, inject, defineProps} from 'vue'; import { ref, inject, defineProps } from 'vue';
import {useIndex} from '../../composables/useIndex.js'; import { useIndex } from '../../composables/useIndex.js';
const props = defineProps({ const props = defineProps({
data: { data: {
@ -97,13 +68,13 @@ const props = defineProps({
}, },
meta: { meta: {
type: Object, type: Object,
default: () => {}, default: () => { },
}, },
}); });
const {toFilterString, router} = useIndex({data: [], meta: {}}); const { toFilterString, router } = useIndex({ data: [], meta: {} }, 'maildispatcher');
const model = ref(props.data === undefined ? {...props.meta.default_model} : {...props.data}); const model = ref(props.data === undefined ? { ...props.meta.default_model } : { ...props.data });
const members = ref(null); const members = ref(null);
const axios = inject('axios'); const axios = inject('axios');

View File

@ -1,39 +1,26 @@
<template> <template>
<page-layout> <page-layout>
<template #toolbar> <template #toolbar>
<page-toolbar-button color="primary" icon="plus" @click.prevent="model = {...meta.default}">Neue Verbindung</page-toolbar-button> <page-toolbar-button color="primary" icon="plus" @click.prevent="model = { ...meta.default }">Neue
Verbindung</page-toolbar-button>
</template> </template>
<ui-popup v-if="model !== null" :heading="model.id ? 'Verbindung bearbeiten' : 'Neue Verbindung'" @close="model = null"> <ui-popup v-if="model !== null" :heading="model.id ? 'Verbindung bearbeiten' : 'Neue Verbindung'"
@close="model = null">
<form @submit.prevent="submit"> <form @submit.prevent="submit">
<section class="grid grid-cols-2 gap-3 mt-6"> <section class="grid grid-cols-2 gap-3 mt-6">
<f-text id="name" v-model="model.name" name="name" label="Bezeichnung" required></f-text> <f-text id="name" v-model="model.name" name="name" label="Bezeichnung" required></f-text>
<f-text id="domain" v-model="model.domain" name="domain" label="Domain" required></f-text> <f-text id="domain" v-model="model.domain" name="domain" label="Domain" required></f-text>
<f-select <f-select id="type" :model-value="model.type.cls" label="Typ" name="type" :options="meta.types"
id="type" :placeholder="''" required @update:model-value="
:model-value="model.type.cls"
label="Typ"
name="type"
:options="meta.types"
:placeholder="''"
required
@update:model-value="
model.type = { model.type = {
cls: $event, cls: $event,
params: {...getType($event).defaults}, params: { ...getType($event).defaults },
} }
" "></f-select>
></f-select>
<template v-for="(field, index) in getType(model.type.cls).fields"> <template v-for="(field, index) in getType(model.type.cls).fields">
<f-text <f-text v-if="field.type === 'text' || field.type === 'password' || field.type === 'email'"
v-if="field.type === 'text' || field.type === 'password' || field.type === 'email'" :id="field.name" :key="index" v-model="model.type.params[field.name]" :label="field.label"
:id="field.name" :type="field.type" :name="field.name" :required="field.is_required"></f-text>
:key="index"
v-model="model.type.params[field.name]"
:label="field.label"
:type="field.type"
:name="field.name"
:required="field.is_required"
></f-text>
</template> </template>
</section> </section>
<section class="flex mt-4 space-x-2"> <section class="flex mt-4 space-x-2">
@ -58,14 +45,12 @@
<td v-text="gateway.domain"></td> <td v-text="gateway.domain"></td>
<td v-text="gateway.type_human"></td> <td v-text="gateway.type_human"></td>
<td> <td>
<ui-boolean-display <ui-boolean-display :value="gateway.works" long-label="Verbindungsstatus"
:value="gateway.works" :label="gateway.works ? 'Verbindung erfolgreich' : 'Verbindung fehlgeschlagen'"></ui-boolean-display>
long-label="Verbindungsstatus"
:label="gateway.works ? 'Verbindung erfolgreich' : 'Verbindung fehlgeschlagen'"
></ui-boolean-display>
</td> </td>
<td> <td>
<a v-tooltip="`Bearbeiten`" href="#" class="inline-flex btn btn-warning btn-sm" @click.prevent="model = {...gateway}"><ui-sprite src="pencil"></ui-sprite></a> <a v-tooltip="`Bearbeiten`" href="#" class="inline-flex btn btn-warning btn-sm"
@click.prevent="model = { ...gateway }"><ui-sprite src="pencil"></ui-sprite></a>
</td> </td>
</tr> </tr>
</table> </table>
@ -79,12 +64,12 @@
</template> </template>
<script setup> <script setup>
import {ref, inject} from 'vue'; import { ref, inject } from 'vue';
import {indexProps, useIndex} from '../../composables/useIndex.js'; import { indexProps, useIndex } from '../../composables/useIndex.js';
import SettingLayout from '../setting/Layout.vue'; import SettingLayout from '../setting/Layout.vue';
const props = defineProps(indexProps); const props = defineProps(indexProps);
const {meta, data, reload} = useIndex(props.data); const { meta, data, reload } = useIndex(props.data, 'mailgateway');
const model = ref(null); const model = ref(null);
const axios = inject('axios'); const axios = inject('axios');

View File

@ -1,17 +1,23 @@
<template> <template>
<page-layout page-class="pb-6"> <page-layout page-class="pb-6">
<template #toolbar> <template #toolbar>
<page-toolbar-button :href="meta.links.create" color="primary" icon="plus">Mitglied anlegen</page-toolbar-button> <page-toolbar-button :href="meta.links.create" color="primary" icon="plus">Mitglied
<page-toolbar-button v-if="hasModule('bill')" :href="meta.links.allpayment" color="primary" icon="invoice">Rechnungen erstellen</page-toolbar-button> anlegen</page-toolbar-button>
<page-toolbar-button v-if="hasModule('bill')" :href="meta.links.sendpayment" color="info" icon="envelope">Rechnungen versenden</page-toolbar-button> <page-toolbar-button v-if="hasModule('bill')" :href="meta.links.allpayment" color="primary"
icon="invoice">Rechnungen erstellen</page-toolbar-button>
<page-toolbar-button v-if="hasModule('bill')" :href="meta.links.sendpayment" color="info"
icon="envelope">Rechnungen versenden</page-toolbar-button>
</template> </template>
<ui-popup v-if="deleting !== null" heading="Mitglied löschen?" @close="deleting.reject()"> <ui-popup v-if="deleting !== null" heading="Mitglied löschen?" @close="deleting.reject()">
<div> <div>
<p class="mt-4">Das Mitglied "{{ deleting.member.fullname }}" löschen?</p> <p class="mt-4">Das Mitglied "{{ deleting.member.fullname }}" löschen?</p>
<p class="mt-2">Alle Zuordnungen (Ausbildungen, Rechnungen, Zahlungen, Tätigkeiten) werden ebenfalls entfernt.</p> <p class="mt-2">Alle Zuordnungen (Ausbildungen, Rechnungen, Zahlungen, Tätigkeiten) werden ebenfalls
<ui-note v-if="!deleting.member.has_nami" class="mt-5" type="warning"> Dieses Mitglied ist nicht in NaMi vorhanden und wird daher nur in der AdReMa gelöscht werden. </ui-note> entfernt.</p>
<ui-note v-if="!deleting.member.has_nami" class="mt-5" type="warning"> Dieses Mitglied ist nicht in NaMi
vorhanden und wird daher nur in der AdReMa gelöscht werden. </ui-note>
<ui-note v-if="deleting.member.has_nami" class="mt-5" type="danger"> <ui-note v-if="deleting.member.has_nami" class="mt-5" type="danger">
Dieses Mitglied ist in NaMi vorhanden und wird daher in NaMi abgemeldet werden. Sofern "Datenweiterverwendung" eingeschaltet ist, wird das Mitglied auf inaktiv gesetzt. Dieses Mitglied ist in NaMi vorhanden und wird daher in NaMi abgemeldet werden. Sofern
"Datenweiterverwendung" eingeschaltet ist, wird das Mitglied auf inaktiv gesetzt.
</ui-note> </ui-note>
<div class="grid grid-cols-2 gap-3 mt-6"> <div class="grid grid-cols-2 gap-3 mt-6">
<a href="#" class="text-center btn btn-danger" @click.prevent="deleting.resolve">Mitglied loschen</a> <a href="#" class="text-center btn btn-danger" @click.prevent="deleting.resolve">Mitglied loschen</a>
@ -20,45 +26,22 @@
</div> </div>
</ui-popup> </ui-popup>
<div class="px-6 py-2 flex border-b border-gray-600 items-center space-x-3"> <div class="px-6 py-2 flex border-b border-gray-600 items-center space-x-3">
<f-text id="search" :model-value="getFilter('search')" name="search" label="Suchen …" size="sm" @update:model-value="setFilter('search', $event)"></f-text> <f-text id="search" :model-value="getFilter('search')" name="search" label="Suchen …" size="sm"
<f-switch v-show="hasModule('bill')" id="ausstand" :model-value="getFilter('ausstand')" label="Nur Ausstände" size="sm" @update:model-value="setFilter('ausstand', $event)"></f-switch> @update:model-value="setFilter('search', $event)"></f-text>
<f-multipleselect <f-switch v-show="hasModule('bill')" id="ausstand" :model-value="getFilter('ausstand')" label="Nur Ausstände"
id="group_ids" size="sm" @update:model-value="setFilter('ausstand', $event)"></f-switch>
:options="meta.groups" <f-multipleselect id="group_ids" :options="meta.groups" :model-value="getFilter('group_ids')"
:model-value="getFilter('group_ids')" label="Gruppierungen" size="sm" name="group_ids"
label="Gruppierungen" @update:model-value="setFilter('group_ids', $event)"></f-multipleselect>
size="sm" <f-select v-show="hasModule('bill')" id="billKinds" name="billKinds" :options="meta.billKinds"
name="group_ids" :model-value="getFilter('bill_kind')" label="Rechnung" size="sm"
@update:model-value="setFilter('group_ids', $event)" @update:model-value="setFilter('bill_kind', $event)"></f-select>
></f-multipleselect> <f-multipleselect id="activity_ids" :options="meta.filterActivities" :model-value="getFilter('activity_ids')"
<f-select label="Tätigkeiten" size="sm" name="activity_ids"
v-show="hasModule('bill')" @update:model-value="setFilter('activity_ids', $event)"></f-multipleselect>
id="billKinds" <f-multipleselect id="subactivity_ids" :options="meta.filterSubactivities"
name="billKinds" :model-value="getFilter('subactivity_ids')" label="Untertätigkeiten" size="sm" name="subactivity_ids"
:options="meta.billKinds" @update:model-value="setFilter('subactivity_ids', $event)"></f-multipleselect>
:model-value="getFilter('bill_kind')"
label="Rechnung"
size="sm"
@update:model-value="setFilter('bill_kind', $event)"
></f-select>
<f-multipleselect
id="activity_ids"
:options="meta.filterActivities"
:model-value="getFilter('activity_ids')"
label="Tätigkeiten"
size="sm"
name="activity_ids"
@update:model-value="setFilter('activity_ids', $event)"
></f-multipleselect>
<f-multipleselect
id="subactivity_ids"
:options="meta.filterSubactivities"
:model-value="getFilter('subactivity_ids')"
label="Untertätigkeiten"
size="sm"
name="subactivity_ids"
@update:model-value="setFilter('subactivity_ids', $event)"
></f-multipleselect>
<button class="btn btn-primary label mr-2" @click.prevent="exportMembers"> <button class="btn btn-primary label mr-2" @click.prevent="exportMembers">
<ui-sprite class="w-3 h-3 xl:mr-2" src="save"></ui-sprite> <ui-sprite class="w-3 h-3 xl:mr-2" src="save"></ui-sprite>
<span class="hidden xl:inline">Exportieren</span> <span class="hidden xl:inline">Exportieren</span>
@ -107,11 +90,14 @@
<div class="text-xs text-gray-200" v-text="member.full_address"></div> <div class="text-xs text-gray-200" v-text="member.full_address"></div>
<div class="flex items-center mt-1 space-x-4"> <div class="flex items-center mt-1 space-x-4">
<tags :member="member"></tags> <tags :member="member"></tags>
<ui-label v-show="hasModule('bill')" class="text-gray-100 block" :value="member.pending_payment" fallback=""></ui-label> <ui-label v-show="hasModule('bill')" class="text-gray-100 block" :value="member.pending_payment"
fallback=""></ui-label>
</div> </div>
<actions class="mt-2" :member="member" @sidebar="openSidebar(index, $event)" @remove="remove(member)"> </actions> <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"> <div class="absolute right-0 top-0 h-full flex items-center mr-2">
<i-link v-tooltip="`Details`" :href="member.links.show"><ui-sprite src="chevron-down" class="w-6 h-6 text-teal-100 -rotate-90"></ui-sprite></i-link> <i-link v-tooltip="`Details`" :href="member.links.show"><ui-sprite src="chevron-down"
class="w-6 h-6 text-teal-100 -rotate-90"></ui-sprite></i-link>
</div> </div>
</ui-box> </ui-box>
</div> </div>
@ -120,22 +106,13 @@
<ui-pagination class="mt-4" :value="meta" :only="['data']"></ui-pagination> <ui-pagination class="mt-4" :value="meta" :only="['data']"></ui-pagination>
</div> </div>
<member-payments <member-payments v-if="single !== null && sidebar === 'payment.index'" :subscriptions="meta.subscriptions"
v-if="single !== null && sidebar === 'payment.index'" :statuses="meta.statuses" :value="data[single]" @close="closeSidebar"></member-payments>
:subscriptions="meta.subscriptions" <member-memberships v-if="single !== null && sidebar === 'membership.index'" :groups="meta.groups"
:statuses="meta.statuses" :activities="meta.formActivities" :subactivities="meta.formSubactivities" :value="data[single]"
:value="data[single]" @close="closeSidebar"></member-memberships>
@close="closeSidebar" <member-courses v-if="single !== null && sidebar === 'courses.index'" :courses="meta.courses" :value="data[single]"
></member-payments> @close="closeSidebar"></member-courses>
<member-memberships
v-if="single !== null && sidebar === 'membership.index'"
:groups="meta.groups"
:activities="meta.formActivities"
:subactivities="meta.formSubactivities"
:value="data[single]"
@close="closeSidebar"
></member-memberships>
<member-courses v-if="single !== null && sidebar === 'courses.index'" :courses="meta.courses" :value="data[single]" @close="closeSidebar"></member-courses>
</page-layout> </page-layout>
</template> </template>
@ -145,15 +122,15 @@ import MemberMemberships from './MemberMemberships.vue';
import MemberCourses from './MemberCourses.vue'; import MemberCourses from './MemberCourses.vue';
import Tags from './Tags.vue'; import Tags from './Tags.vue';
import Actions from './index/Actions.vue'; import Actions from './index/Actions.vue';
import {indexProps, useIndex} from '../../composables/useIndex.js'; import { indexProps, useIndex } from '../../composables/useIndex.js';
import {ref, defineProps} from 'vue'; import { ref, defineProps } from 'vue';
const sidebar = ref(null); const sidebar = ref(null);
const single = ref(null); const single = ref(null);
const deleting = ref(null); const deleting = ref(null);
const props = defineProps(indexProps); const props = defineProps(indexProps);
var {router, data, meta, getFilter, setFilter, filterString} = useIndex(props.data); var { router, data, meta, getFilter, setFilter, filterString } = useIndex(props.data, 'member');
function exportMembers() { function exportMembers() {
window.open(`/member-export?filter=${filterString.value}`); window.open(`/member-export?filter=${filterString.value}`);
@ -161,7 +138,7 @@ function exportMembers() {
async function remove(member) { async function remove(member) {
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
deleting.value = {resolve, reject, member}; deleting.value = { resolve, reject, member };
}) })
.then(() => { .then(() => {
router.delete(`/member/${member.id}`); router.delete(`/member/${member.id}`);

View File

@ -22,7 +22,8 @@ class InitializeMembersTest extends TestCase
app(SearchFake::class)->fetches(1, 0, 100, [ app(SearchFake::class)->fetches(1, 0, 100, [
MemberEntry::factory()->toMember(['groupId' => 100, 'id' => 20]), MemberEntry::factory()->toMember(['groupId' => 100, 'id' => 20]),
]); ]);
FullMemberAction::shouldRun()->once()->shouldReceive('configureJob'); FullMemberAction::partialMock()->shouldReceive('configureJob')->once();
FullMemberAction::partialMock()->shouldReceive('handle')->once();
app(InitializeMembers::class)->handle($api); app(InitializeMembers::class)->handle($api);
} }
@ -33,7 +34,8 @@ class InitializeMembersTest extends TestCase
app(SearchFake::class)->fetches(1, 0, 100, [ app(SearchFake::class)->fetches(1, 0, 100, [
MemberEntry::factory()->toMember(['groupId' => 100, 'id' => 20]), MemberEntry::factory()->toMember(['groupId' => 100, 'id' => 20]),
]); ]);
FullMemberAction::shouldRun()->once()->shouldReceive('configureJob'); FullMemberAction::partialMock()->shouldReceive('configureJob')->once();
FullMemberAction::partialMock()->shouldReceive('handle')->once();
Artisan::call('member:pull'); Artisan::call('member:pull');
} }

View File

@ -4,19 +4,22 @@ namespace Tests\Feature\Member;
use App\Course\Models\Course; use App\Course\Models\Course;
use App\Course\Models\CourseMember; use App\Course\Models\CourseMember;
use App\Lib\Events\ClientMessage;
use App\Lib\Events\JobFinished;
use App\Lib\Events\JobStarted;
use App\Member\Actions\MemberDeleteAction;
use App\Member\Actions\NamiDeleteMemberAction; use App\Member\Actions\NamiDeleteMemberAction;
use App\Member\Member; use App\Member\Member;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
use Tests\TestCase; use Tests\TestCase;
use Zoomyboy\LaravelNami\Fakes\MemberFake;
class DeleteTest extends TestCase class DeleteTest extends TestCase
{ {
use DatabaseTransactions; use DatabaseTransactions;
public function testItDeletesMemberFromNami(): void public function testItFiresJob(): void
{ {
Queue::fake(); Queue::fake();
$this->login()->loginNami(); $this->login()->loginNami();
@ -26,7 +29,17 @@ class DeleteTest extends TestCase
$response->assertRedirect('/member'); $response->assertRedirect('/member');
NamiDeleteMemberAction::assertPushed(); MemberDeleteAction::assertPushed(fn ($action, $parameters) => $parameters[0] === $member->id);
}
public function testItDeletesMemberFromNami(): void
{
$this->login()->loginNami();
NamiDeleteMemberAction::partialMock()->shouldReceive('handle')->with(123)->once();
$member = Member::factory()->defaults()->inNami(123)->create();
MemberDeleteAction::run($member->id);
$this->assertDatabaseMissing('members', [ $this->assertDatabaseMissing('members', [
'id' => $member->id, 'id' => $member->id,
]); ]);
@ -34,38 +47,33 @@ class DeleteTest extends TestCase
public function testItDoesntRunActionWhenMemberIsNotInNami(): void public function testItDoesntRunActionWhenMemberIsNotInNami(): void
{ {
Queue::fake();
$this->login()->loginNami(); $this->login()->loginNami();
NamiDeleteMemberAction::partialMock()->shouldReceive('handle')->never();
$member = Member::factory()->defaults()->create(); $member = Member::factory()->defaults()->create();
$response = $this->from('/member')->delete("/member/{$member->id}"); MemberDeleteAction::run($member->id);
$response->assertRedirect('/member');
Queue::assertNotPushed(NamiDeleteMemberAction::class);
$this->assertDatabaseMissing('members', [
'id' => $member->id,
]);
} }
public function testTheActionDeletesNamiMember(): void
{
app(MemberFake::class)->deletes(123, Carbon::parse('yesterday'));
$this->withoutExceptionHandling()->login()->loginNami();
$member = Member::factory()->defaults()->inNami(123)->create();
NamiDeleteMemberAction::dispatch(123);
app(MemberFake::class)->assertDeleted(123, Carbon::parse('yesterday'));
}
public function testItDeletesMembersWithCourses(): void public function testItDeletesMembersWithCourses(): void
{ {
$this->withoutExceptionHandling()->login()->loginNami(); $this->withoutExceptionHandling()->login()->loginNami();
$member = Member::factory()->defaults()->has(CourseMember::factory()->for(Course::factory()), 'courses')->create(); $member = Member::factory()->defaults()->has(CourseMember::factory()->for(Course::factory()), 'courses')->create();
$member->delete(); MemberDeleteAction::run($member->id);
$this->assertDatabaseCount('members', 0); $this->assertDatabaseCount('members', 0);
} }
public function testItFiresEventWhenFinished(): void
{
Event::fake([JobStarted::class, JobFinished::class]);
$this->withoutExceptionHandling()->login()->loginNami();
$member = Member::factory()->defaults()->create(['firstname' => 'Max', 'lastname' => 'Muster']);
MemberDeleteAction::dispatch($member->id);
Event::assertDispatched(JobStarted::class, fn ($event) => $event->broadcastOn()->name === 'member' && $event->message === 'Lösche Mitglied Max Muster' && $event->reload === false);
Event::assertDispatched(JobFinished::class, fn ($event) => $event->message === 'Mitglied Max Muster gelöscht' && $event->reload === true);
}
} }

View File

@ -0,0 +1,26 @@
<?php
namespace Tests\Feature\Member;
use App\Member\Actions\NamiDeleteMemberAction;
use App\Member\Member;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;
use Zoomyboy\LaravelNami\Fakes\MemberFake;
class NamiDeleteMemberActionTest extends TestCase
{
use DatabaseTransactions;
public function testTheActionDeletesNamiMember(): void
{
app(MemberFake::class)->deletes(123, Carbon::parse('yesterday'));
$this->withoutExceptionHandling()->login()->loginNami();
Member::factory()->defaults()->inNami(123)->create();
NamiDeleteMemberAction::dispatch(123);
app(MemberFake::class)->assertDeleted(123, Carbon::parse('yesterday'));
}
}