Add options for search layers
continuous-integration/drone/push Build is passing Details

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

View File

@ -11,7 +11,7 @@ class RedirectIfNotInitializedMiddleware
/**
* @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.

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('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 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>
<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>
<div class="bg-gray-800 rounded-xl overflow-hidden shadow-lg p-6">
<slot></slot>

View File

@ -1,63 +1,57 @@
<template>
<div>
<div v-if="step === 0">
<full-page-heading>Willkommen im Adrema-Setup.<br /></full-page-heading>
<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>
<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.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>
</div>
<div v-if="step === 1" class="flex flex-col" style="width: 90vw; height: 90vh">
<form @submit.prevent="storeSearch" class="border-2 border-primary-700 border-solid p-3 rounded-lg grid grid-cols-3 gap-3 flex-none">
<div v-if="step === 1" class="grid grid-cols-5 w-full gap-3">
<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">
<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
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>
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>
<f-text
<f-select
v-model="values.params.gruppierung1Id"
label="Diözesan-Gruppierung"
name="gruppierung1Id"
id="gruppierung1Id"
type="tel"
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."
></f-text>
<f-text
></f-select>
<f-select
v-model="values.params.gruppierung2Id"
label="Bezirks-Gruppierung"
name="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."
:disabled="!values.params.gruppierung1Id"
@input="search"
@input="loadSearchLayer(2, $event, search)"
size="sm"
></f-text>
<f-text
:options="searchLayerOptions[1]"
></f-select>
<f-select
v-model="values.params.gruppierung3Id"
label="Stammes-Gruppierung"
name="gruppierung3Id"
id="gruppierung3Id"
type="tel"
size="sm"
@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."
:disabled="!values.params.gruppierung1Id || !values.params.gruppierung2Id"
></f-text>
:options="searchLayerOptions[2]"
></f-select>
<f-select
v-model="values.params.mglStatusId"
label="Mitglieds-Status"
@ -86,12 +80,12 @@
hint="Mitglieder finden, die direktes Mitglied in einer Untergruppe der kleinsten befüllten Gruppierung sind."
size="sm"
></f-switch>
<div class="col-span-full">
<button type="submit" class="btn btn-primary btn-sm">Weiter</button>
<div class="col-span-full flex justify-center">
<ui-button :is-loading="loading" class="px-10" type="submit">Weiter</ui-button>
</div>
</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">
<thead>
<th>GruppierungsNr</th>
@ -114,9 +108,10 @@
<v-pages class="mt-4" :value="preview" @reload="reloadPage"></v-pages>
</div>
</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 v-if="step === 2">
<full-page-heading>Standard-Gruppierung</full-page-heading>
<div class="prose prose-invert">
<p>Bitte gib hier deine Standard-Gruppierungsnummer ein.</p>
<p>Dieser Gruppierung werden Mitglieder automatisch zugeordnet,<br />falls nichts anderes angegeben wurde.</p>
@ -128,6 +123,7 @@
</form>
</div>
<div v-if="step === 3">
<full-page-heading>Einrichtung abgeschlossen</full-page-heading>
<div class="prose prose-invert">
<p>Wir werden nun die Mitgliederdaten anhand deiner festgelegten Kriterien abrufen.</p>
<p>Per Klick auf "Abschließen" gelangst du zum Dashboard</p>
@ -150,6 +146,8 @@ export default {
data: function () {
return {
searchLayerOptions: [[], [], []],
loading: false,
preview: null,
states: [
{id: 'INAKTIV', name: 'Inaktiv'},
@ -188,23 +186,57 @@ export default {
await this.loadSearchResult(page);
},
async check() {
this.loading = true;
try {
await this.axios.post('/nami/login-check', this.values);
this.step = 1;
await this.loadSearchResult(1);
await this.loadSearchLayer(0, null, () => '');
this.step = 1;
} catch (e) {
this.errorsFromException(e);
} finally {
this.loading = false;
}
},
search: debounce(async function () {
await this.loadSearchResult(1);
}, 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) {
this.loading = true;
try {
var result = await this.axios.post('/nami/search', {...this.values, page: page});
this.preview = result.data;
} catch (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\Initialize\Actions\InitializeAction;
use App\Initialize\Actions\InitializeFormAction;
use App\Initialize\Actions\NamiGetSearchLayerAction;
use App\Initialize\Actions\NamiLoginCheckAction;
use App\Initialize\Actions\NamiSearchAction;
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::get('/', DashboardIndexAction::class)->name('home');
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('/api/member/search', SearchAction::class)->name('member.search');
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);
}
}