Add options for search layers

This commit is contained in:
philipp lang 2023-05-18 01:13:28 +02:00
parent 50dc714f18
commit f1c55bedce
11 changed files with 288 additions and 31 deletions
app
packages
resources/js
routes
tests/Feature/Initializer

View File

@ -11,7 +11,7 @@ class RedirectIfNotInitializedMiddleware
/** /**
* @var array<int, string> * @var array<int, string>
*/ */
public array $dontRedirect = ['initialize.form', 'initialize.store', 'nami.login-check', 'nami.search']; public array $dontRedirect = ['initialize.form', 'initialize.store', 'nami.login-check', 'nami.search', 'nami.get-search-layer'];
/** /**
* Handle an incoming request. * Handle an incoming request.

View File

@ -0,0 +1,49 @@
<?php
namespace App\Initialize\Actions;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Collection;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
use Zoomyboy\LaravelNami\Data\SearchLayerOption;
use Zoomyboy\LaravelNami\Enum\SearchLayer;
use Zoomyboy\LaravelNami\Nami;
class NamiGetSearchLayerAction
{
use AsAction;
/**
* @param array<string, mixed> $input
*
* @return Collection<int, SearchLayerOption>
*/
public function handle(array $input): Collection
{
return Nami::login((int) $input['mglnr'], $input['password'])->searchLayerOptions(
SearchLayer::from($input['layer'] ?: 0),
$input['parent'] ?: null
);
}
/**
* @return array<string, string>
*/
public function rules(): array
{
return [
'mglnr' => 'required|numeric|min:0',
'password' => 'required|string',
'parent' => 'present',
'layer' => 'required|numeric',
];
}
public function asController(ActionRequest $request): JsonResponse
{
$response = $this->handle($request->validated());
return response()->json($response);
}
}

@ -1 +1 @@
Subproject commit 33875d36fa5bd6fab4147e95f4aa705092f42d93 Subproject commit c5ea29af1bb1591238bb037da93739f5bd874334

8
resources/js/app.js vendored
View File

@ -42,6 +42,14 @@ Vue.component('toolbar-button', ToolbarButton);
Vue.component('page-layout', PageLayout); Vue.component('page-layout', PageLayout);
Vue.component('save-button', () => import(/* webpackChunkName: "form" */ './components/SaveButton')); Vue.component('save-button', () => import(/* webpackChunkName: "form" */ './components/SaveButton'));
// ------------------------------ Full components ------------------------------
Vue.component('full-page-heading', () => import(/* webpackChunkName: "full" */ './components/Full/PageHeading.vue'));
// ------------------------------- UI Components -------------------------------
Vue.component('ui-button', () => import(/* webpackChunkName: "ui" */ './components/Ui/Button.vue'));
Vue.component('ui-spinner', () => import(/* webpackChunkName: "ui" */ './components/Ui/Spinner.vue'));
// ----------------------------------- init ------------------------------------
const el = document.getElementById('app'); const el = document.getElementById('app');
const pinia = createPinia(); const pinia = createPinia();

View File

@ -0,0 +1,7 @@
<template>
<h1 class="text-xl border-b-2 pb-1 mb-4 text-primary-100 text-center border-primary-800"><slot></slot></h1>
</template>
<script>
export default {};
</script>

View File

@ -0,0 +1,24 @@
<template>
<button class="btn btn-primary relative group">
<div :class="{hidden: !isLoading, flex: isLoading}" class="absolute items-center top-0 h-full left-0 ml-2">
<ui-spinner class="border-primary-400 w-6 h-6 group-hover:border-primary-200"></ui-spinner>
</div>
Weiter
</button>
</template>
<script>
import {menuStore} from '../../stores/menuStore.js';
export default {
data: function () {
return {};
},
props: {
isLoading: {
type: Boolean,
default: false,
},
},
};
</script>

View File

@ -0,0 +1,57 @@
<template>
<div :class="`spin-${type}`">
<div v-if="type === 'ring'"></div>
<div v-if="type === 'ring'"></div>
<div v-if="type === 'ring'"></div>
<div v-if="type === 'ring'"></div>
</div>
</template>
<script>
export default {
props: {
type: {
type: String,
default: () => 'ring',
},
},
};
</script>
<style>
.spin-ring {
display: inline-block;
position: relative;
}
.spin-ring div {
box-sizing: border-box;
display: block;
position: absolute;
width: 100%;
height: 100%;
border: 3px solid #fff;
border-radius: 50%;
animation: ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
border-top-color: inherit;
border-right-color: transparent;
border-bottom-color: transparent;
border-left-color: transparent;
}
.spin-ring div:nth-child(1) {
animation-delay: -0.45s;
}
.spin-ring div:nth-child(2) {
animation-delay: -0.3s;
}
.spin-ring div:nth-child(3) {
animation-delay: -0.15s;
}
@keyframes ring {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

View File

@ -1,5 +1,5 @@
<template> <template>
<div id="app" class="bg-gray-900 font-sans flex flex-col grow items-center justify-center"> <div id="app" class="bg-gray-900 font-sans flex flex-col grow items-center justify-center p-6">
<v-notification class="fixed z-40 right-0 bottom-0 mb-3 mr-3"></v-notification> <v-notification class="fixed z-40 right-0 bottom-0 mb-3 mr-3"></v-notification>
<div class="bg-gray-800 rounded-xl overflow-hidden shadow-lg p-6"> <div class="bg-gray-800 rounded-xl overflow-hidden shadow-lg p-6">
<slot></slot> <slot></slot>

View File

@ -1,63 +1,57 @@
<template> <template>
<div> <div>
<div v-if="step === 0"> <div v-if="step === 0">
<full-page-heading>Willkommen im Adrema-Setup.<br /></full-page-heading>
<div class="prose prose-invert"> <div class="prose prose-invert">
<p>Willkommen im Adrema-Setup.<br /></p> <p>Bitte gib deine NaMi-Zugangsdaten ein,<br />um eine erste Synchronisation durchzuführen.</p>
<p>
Bitte gib deine NaMi-Zugangsdaten ein,<br />
um eine erste Synchronisation durchzuführen.
</p>
</div> </div>
<form @submit.prevent="check" class="grid gap-3 mt-5"> <form @submit.prevent="check" class="grid gap-3 mt-5">
<f-text v-model="values.mglnr" label="Mitgliedsnummer" name="mglnr" id="mglnr" type="tel" required></f-text> <f-text v-model="values.mglnr" label="Mitgliedsnummer" name="mglnr" id="mglnr" type="tel" required></f-text>
<f-text v-model="values.password" type="password" label="Passwort" name="password" id="password" required></f-text> <f-text v-model="values.password" type="password" label="Passwort" name="password" id="password" required></f-text>
<button type="submit" class="btn w-full btn-primary mt-6 inline-block">Weiter</button> <ui-button class="mt-6" :is-loading="loading" type="submit">Weiter</ui-button>
</form> </form>
</div> </div>
<div v-if="step === 1" class="flex flex-col" style="width: 90vw; height: 90vh"> <div v-if="step === 1" class="grid grid-cols-5 w-full gap-3">
<form @submit.prevent="storeSearch" class="border-2 border-primary-700 border-solid p-3 rounded-lg grid grid-cols-3 gap-3 flex-none"> <full-page-heading class="col-span-full !mb-0">Suchkriterien festlegen</full-page-heading>
<form @submit.prevent="storeSearch" class="border-2 border-primary-800 border-solid p-3 rounded-lg grid gap-3 col-span-2">
<div class="prose prose-invert max-w-none col-span-full"> <div class="prose prose-invert max-w-none col-span-full">
<p> <p>
Lege hier die Suchkriterien für den Abruf der Mitglieder-Daten fest. Mit diesen Suchkriterien wird im Anschluss eine Mitgliedersuche in NaMi durchgeführt. Alle Mitglieder, die Lege hier die Suchkriterien für den Abruf der Mitglieder-Daten fest. Mit diesen Suchkriterien wird im Anschluss eine Mitgliedersuche in NaMi durchgeführt. Alle Mitglieder, die
dann dort auftauchen werden in die Adrema übernommen. Dir wird hier eine Vorschau eingeblendet, damit du sicherstellen kannst, dass die Suchkriterien die richtigen sind. dann dort auftauchen werden in die Adrema übernommen. Dir wird hier eine Vorschau eingeblendet, damit du sicherstellen kannst, dass die Suchkriterien die richtigen sind.
</p> </p>
<p>
Außerdem werden diese Suchkriterien bei jedem neuen Abgleich (der automatisch täglich erfolgt) angewendet. Du kannst die Suchkriterien in den globalen Einstellungen jederzeit
ändern.
</p>
</div> </div>
<f-text <f-select
v-model="values.params.gruppierung1Id" v-model="values.params.gruppierung1Id"
label="Diözesan-Gruppierung" label="Diözesan-Gruppierung"
name="gruppierung1Id" name="gruppierung1Id"
id="gruppierung1Id" id="gruppierung1Id"
type="tel"
size="sm" size="sm"
@input="search" :options="searchLayerOptions[0]"
@input="loadSearchLayer(1, $event, search)"
hint="Gruppierungs-Nummer einer Diözese, auf die die Mitglieder passen sollen. I.d.R. ist das die Gruppierungsnummer deiner Diözese. Entspricht dem Feld '1. Ebene' in der NaMi Suche." hint="Gruppierungs-Nummer einer Diözese, auf die die Mitglieder passen sollen. I.d.R. ist das die Gruppierungsnummer deiner Diözese. Entspricht dem Feld '1. Ebene' in der NaMi Suche."
></f-text> ></f-select>
<f-text <f-select
v-model="values.params.gruppierung2Id" v-model="values.params.gruppierung2Id"
label="Bezirks-Gruppierung" label="Bezirks-Gruppierung"
name="gruppierung2Id" name="gruppierung2Id"
id="gruppierung2Id" id="gruppierung2Id"
type="tel"
hint="Gruppierungs-Nummer eines Bezirks, auf die die Mitglieder passen sollen. I.d.R. ist das die Gruppierungsnummer deines Bezirks. Entspricht dem Feld '2. Ebene' in der NaMi Suche. Fülle dieses Feld aus, um Mitglieder auf einen bestimmten Bezirk zu begrenzen." hint="Gruppierungs-Nummer eines Bezirks, auf die die Mitglieder passen sollen. I.d.R. ist das die Gruppierungsnummer deines Bezirks. Entspricht dem Feld '2. Ebene' in der NaMi Suche. Fülle dieses Feld aus, um Mitglieder auf einen bestimmten Bezirk zu begrenzen."
:disabled="!values.params.gruppierung1Id" :disabled="!values.params.gruppierung1Id"
@input="search" @input="loadSearchLayer(2, $event, search)"
size="sm" size="sm"
></f-text> :options="searchLayerOptions[1]"
<f-text ></f-select>
<f-select
v-model="values.params.gruppierung3Id" v-model="values.params.gruppierung3Id"
label="Stammes-Gruppierung" label="Stammes-Gruppierung"
name="gruppierung3Id" name="gruppierung3Id"
id="gruppierung3Id" id="gruppierung3Id"
type="tel"
size="sm" size="sm"
@input="search" @input="search"
hint="Gruppierungs-Nummer deines Stammes, auf die die Mitglieder passen sollen. I.d.R. ist das die Gruppierungsnummer deines Stammes. Entspricht dem Feld '3. Ebene' in der NaMi Suche. Fülle dieses Feld aus, um Mitglieder auf einen bestimmten Stamm zu beschränken." hint="Gruppierungs-Nummer deines Stammes, auf die die Mitglieder passen sollen. I.d.R. ist das die Gruppierungsnummer deines Stammes. Entspricht dem Feld '3. Ebene' in der NaMi Suche. Fülle dieses Feld aus, um Mitglieder auf einen bestimmten Stamm zu beschränken."
:disabled="!values.params.gruppierung1Id || !values.params.gruppierung2Id" :disabled="!values.params.gruppierung1Id || !values.params.gruppierung2Id"
></f-text> :options="searchLayerOptions[2]"
></f-select>
<f-select <f-select
v-model="values.params.mglStatusId" v-model="values.params.mglStatusId"
label="Mitglieds-Status" label="Mitglieds-Status"
@ -86,12 +80,12 @@
hint="Mitglieder finden, die direktes Mitglied in einer Untergruppe der kleinsten befüllten Gruppierung sind." hint="Mitglieder finden, die direktes Mitglied in einer Untergruppe der kleinsten befüllten Gruppierung sind."
size="sm" size="sm"
></f-switch> ></f-switch>
<div class="col-span-full"> <div class="col-span-full flex justify-center">
<button type="submit" class="btn btn-primary btn-sm">Weiter</button> <ui-button :is-loading="loading" class="px-10" type="submit">Weiter</ui-button>
</div> </div>
</form> </form>
<section class="grow border-2 border-primary-700 border-solid p-3 rounded-lg mt-4" v-if="preview !== null && preview.data.length"> <section class="col-span-3 text-sm col-span-3" v-if="preview !== null && preview.data.length">
<table cellspacing="0" cellpadding="0" border="0" class="custom-table custom-table-sm hidden md:table"> <table cellspacing="0" cellpadding="0" border="0" class="custom-table custom-table-sm hidden md:table">
<thead> <thead>
<th>GruppierungsNr</th> <th>GruppierungsNr</th>
@ -114,9 +108,10 @@
<v-pages class="mt-4" :value="preview" @reload="reloadPage"></v-pages> <v-pages class="mt-4" :value="preview" @reload="reloadPage"></v-pages>
</div> </div>
</section> </section>
<section class="grow items-center justify-center flex text-xl text-gray-200 border-2 border-primary-700 border-solid p-3 rounded-lg mt-4" v-else>Keine Mitglieder gefunden</section> <section class="col-span-3 items-center justify-center flex text-xl text-gray-200 border-2 border-primary-800 border-solid p-3 rounded-lg mt-4" v-else>Keine Mitglieder gefunden</section>
</div> </div>
<div v-if="step === 2"> <div v-if="step === 2">
<full-page-heading>Standard-Gruppierung</full-page-heading>
<div class="prose prose-invert"> <div class="prose prose-invert">
<p>Bitte gib hier deine Standard-Gruppierungsnummer ein.</p> <p>Bitte gib hier deine Standard-Gruppierungsnummer ein.</p>
<p>Dieser Gruppierung werden Mitglieder automatisch zugeordnet,<br />falls nichts anderes angegeben wurde.</p> <p>Dieser Gruppierung werden Mitglieder automatisch zugeordnet,<br />falls nichts anderes angegeben wurde.</p>
@ -128,6 +123,7 @@
</form> </form>
</div> </div>
<div v-if="step === 3"> <div v-if="step === 3">
<full-page-heading>Einrichtung abgeschlossen</full-page-heading>
<div class="prose prose-invert"> <div class="prose prose-invert">
<p>Wir werden nun die Mitgliederdaten anhand deiner festgelegten Kriterien abrufen.</p> <p>Wir werden nun die Mitgliederdaten anhand deiner festgelegten Kriterien abrufen.</p>
<p>Per Klick auf "Abschließen" gelangst du zum Dashboard</p> <p>Per Klick auf "Abschließen" gelangst du zum Dashboard</p>
@ -150,6 +146,8 @@ export default {
data: function () { data: function () {
return { return {
searchLayerOptions: [[], [], []],
loading: false,
preview: null, preview: null,
states: [ states: [
{id: 'INAKTIV', name: 'Inaktiv'}, {id: 'INAKTIV', name: 'Inaktiv'},
@ -188,23 +186,57 @@ export default {
await this.loadSearchResult(page); await this.loadSearchResult(page);
}, },
async check() { async check() {
this.loading = true;
try { try {
await this.axios.post('/nami/login-check', this.values); await this.axios.post('/nami/login-check', this.values);
this.step = 1;
await this.loadSearchResult(1); await this.loadSearchResult(1);
await this.loadSearchLayer(0, null, () => '');
this.step = 1;
} catch (e) { } catch (e) {
this.errorsFromException(e); this.errorsFromException(e);
} finally {
this.loading = false;
} }
}, },
search: debounce(async function () { search: debounce(async function () {
await this.loadSearchResult(1); await this.loadSearchResult(1);
}, 500), }, 500),
async loadSearchLayer(parentLayer, parent, after) {
this.loading = true;
try {
var result = await this.axios.post('/nami/get-search-layer', {...this.values, layer: parentLayer, parent});
this.searchLayerOptions = this.searchLayerOptions.map((layers, index) => {
if (index < parentLayer) {
return layers;
}
var groupIndex = index + 1;
this.values.params[`gruppierung${groupIndex}Id`] = null;
if (index === parentLayer) {
return result.data;
}
return [];
});
after();
} catch (e) {
this.errorsFromException(e);
} finally {
this.loading = false;
}
},
async loadSearchResult(page) { async loadSearchResult(page) {
this.loading = true;
try { try {
var result = await this.axios.post('/nami/search', {...this.values, page: page}); var result = await this.axios.post('/nami/search', {...this.values, page: page});
this.preview = result.data; this.preview = result.data;
} catch (e) { } catch (e) {
this.errorsFromException(e); this.errorsFromException(e);
} finally {
this.loading = false;
} }
}, },
}, },

View File

@ -17,6 +17,7 @@ use App\Dashboard\Actions\IndexAction as DashboardIndexAction;
use App\Efz\ShowEfzDocumentAction; use App\Efz\ShowEfzDocumentAction;
use App\Initialize\Actions\InitializeAction; use App\Initialize\Actions\InitializeAction;
use App\Initialize\Actions\InitializeFormAction; use App\Initialize\Actions\InitializeFormAction;
use App\Initialize\Actions\NamiGetSearchLayerAction;
use App\Initialize\Actions\NamiLoginCheckAction; use App\Initialize\Actions\NamiLoginCheckAction;
use App\Initialize\Actions\NamiSearchAction; use App\Initialize\Actions\NamiSearchAction;
use App\Member\Actions\ExportAction; use App\Member\Actions\ExportAction;
@ -41,6 +42,7 @@ Route::group(['namespace' => 'App\\Http\\Controllers'], function (): void {
Route::group(['middleware' => 'auth:web'], function (): void { Route::group(['middleware' => 'auth:web'], function (): void {
Route::get('/', DashboardIndexAction::class)->name('home'); Route::get('/', DashboardIndexAction::class)->name('home');
Route::post('/nami/login-check', NamiLoginCheckAction::class)->name('nami.login-check'); Route::post('/nami/login-check', NamiLoginCheckAction::class)->name('nami.login-check');
Route::post('/nami/get-search-layer', NamiGetSearchLayerAction::class)->name('nami.get-search-layer');
Route::post('/nami/search', NamiSearchAction::class)->name('nami.search'); Route::post('/nami/search', NamiSearchAction::class)->name('nami.search');
Route::post('/api/member/search', SearchAction::class)->name('member.search'); Route::post('/api/member/search', SearchAction::class)->name('member.search');
Route::get('/initialize', InitializeFormAction::class)->name('initialize.form'); Route::get('/initialize', InitializeFormAction::class)->name('initialize.form');

View File

@ -0,0 +1,78 @@
<?php
namespace Tests\Feature\Initializer;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;
use Zoomyboy\LaravelNami\Authentication\Auth;
use Zoomyboy\LaravelNami\Fakes\SearchLayerFake;
class GetSearchLayerActionTest extends TestCase
{
use DatabaseTransactions;
public function setUp(): void
{
parent::setUp();
$this->login();
Auth::success(333, 'secret');
}
public function testItFindsRoots(): void
{
$this->withoutExceptionHandling();
app(SearchLayerFake::class)->fetches('1', [
['descriptor' => 'aa', 'id' => 5],
]);
$response = $this->postJson('/nami/get-search-layer', [
'layer' => 0,
'parent' => null,
'mglnr' => 333,
'password' => 'secret',
]);
$response->assertStatus(200);
$response->assertJsonPath('0.name', 'aa');
$response->assertJsonPath('0.id', 5);
}
public function testItFindsFirstLayer(): void
{
$this->withoutExceptionHandling();
app(SearchLayerFake::class)->fetches('2/gruppierung1/20', [
['descriptor' => 'aa', 'id' => 5],
]);
$response = $this->postJson('/nami/get-search-layer', [
'layer' => 1,
'parent' => 20,
'mglnr' => 333,
'password' => 'secret',
]);
$response->assertStatus(200);
$response->assertJsonPath('0.name', 'aa');
$response->assertJsonPath('0.id', 5);
}
public function testItFindsSecondLayer(): void
{
$this->withoutExceptionHandling();
app(SearchLayerFake::class)->fetches('3/gruppierung2/30', [
['descriptor' => 'aa', 'id' => 5],
]);
$response = $this->postJson('/nami/get-search-layer', [
'layer' => 2,
'parent' => 30,
'mglnr' => 333,
'password' => 'secret',
]);
$response->assertStatus(200);
$response->assertJsonPath('0.name', 'aa');
$response->assertJsonPath('0.id', 5);
}
}