Compare commits

...

5 Commits

Author SHA1 Message Date
philipp lang 738128a4f4 Fix prevention for leaders
continuous-integration/drone/push Build is failing Details
2024-07-12 18:06:52 +02:00
philipp lang 83eb61fc2d Add prevention conditions 2024-07-12 18:05:11 +02:00
philipp lang 38dd7cd0bb Add conditions form 2024-07-12 17:33:38 +02:00
philipp lang 30fddce1cf Lint 2024-07-12 17:31:49 +02:00
philipp lang e05036e801 Lint 2024-07-12 00:23:20 +02:00
17 changed files with 157 additions and 57 deletions

View File

@ -37,6 +37,7 @@ class FormStoreAction
'export' => 'nullable|array',
'needs_prevention' => 'present|boolean',
'prevention_text' => 'array',
'prevention_conditions' => 'array',
];
}

View File

@ -3,6 +3,7 @@
namespace App\Form\Actions;
use App\Form\Models\Form;
use App\Lib\Editor\Condition;
use App\Lib\Events\Succeeded;
use Illuminate\Http\JsonResponse;
use Lorisleiva\Actions\Concerns\AsAction;
@ -36,6 +37,7 @@ class FormUpdateAction
'export' => 'nullable|array',
'needs_prevention' => 'present|boolean',
'prevention_text' => 'array',
'prevention_conditions' => 'array',
];
}

View File

@ -2,6 +2,7 @@
namespace App\Form\Actions;
use App\Form\Editor\FormConditionResolver;
use App\Form\Models\Participant;
use App\Prevention\Mails\PreventionRememberMail;
use App\Prevention\PreventionSettings;
@ -23,6 +24,10 @@ class PreventionRememberAction
->orWhereNull('last_remembered_at')
);
foreach ($query->get() as $participant) {
if (!app(FormConditionResolver::class)->forParticipant($participant)->filterCondition($participant->form->prevention_conditions)) {
continue;
}
if ($participant->getFields()->getMailRecipient() === null || count($participant->preventions()) === 0) {
continue;
}

View File

@ -5,6 +5,7 @@ namespace App\Form\Models;
use App\Form\Data\ExportData;
use App\Form\Data\FieldCollection;
use App\Form\Data\FormConfigData;
use App\Lib\Editor\Condition;
use App\Lib\Editor\EditorData;
use Cviebrock\EloquentSluggable\Sluggable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -39,6 +40,7 @@ class Form extends Model implements HasMedia
'export' => ExportData::class,
'needs_prevention' => 'boolean',
'prevention_text' => EditorData::class,
'prevention_conditions' => Condition::class,
];
/** @var array<int, string> */

View File

@ -52,6 +52,7 @@ class FormResource extends JsonResource
'export' => $this->export,
'needs_prevention' => $this->needs_prevention,
'prevention_text' => $this->prevention_text,
'prevention_conditions' => $this->prevention_conditions,
'links' => [
'participant_index' => route('form.participant.index', ['form' => $this->getModel(), 'parent' => null]),
'participant_root_index' => route('form.participant.index', ['form' => $this->getModel(), 'parent' => -1]),
@ -99,6 +100,7 @@ class FormResource extends JsonResource
'prevention_text' => EditorData::default(),
'id' => null,
'export' => ExportData::from([]),
'prevention_conditions' => ['mode' => 'all', 'ifs' => []],
],
'section_default' => [
'name' => '',

View File

@ -2,7 +2,6 @@
namespace App\Lib\Editor;
use Illuminate\Database\Eloquent\Model;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\DataCollection;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
@ -21,22 +20,12 @@ class Condition extends Data
public static function fromMedia(Media $media): self
{
return static::withoutMagicalCreationFrom($media->getCustomProperty('conditions') ?: [
'mode' => 'any',
'ifs' => [],
]);
return $media->getCustomProperty('conditions') ? static::withoutMagicalCreationFrom($media->getCustomProperty('conditions')) : static::default();
}
/**
* @param array<string, mixed> $block
*/
public static function fromBlock(array $block): self
public static function defaults(): self
{
return static::withoutMagicalCreationFrom([
'mode' => data_get($block, 'tunes.condition.mode', 'any'),
'ifs' => data_get($block, 'tunes.condition.ifs', []),
]);
return static::withoutMagicalCreationFrom(['mode' => 'any', 'ifs' => []]);
}
public function hasStatements(): bool

View File

@ -21,6 +21,9 @@ abstract class ConditionResolver
*/
public function filterBlock(array $block): bool
{
return $this->filterCondition(Condition::fromBlock($block));
return $this->filterCondition(Condition::withoutMagicalCreationFrom([
'mode' => data_get($block, 'tunes.condition.mode', 'any'),
'ifs' => data_get($block, 'tunes.condition.ifs', []),
]));
}
}

View File

@ -347,9 +347,6 @@ class Member extends Model implements Geolocatable
public function preventions(?Carbon $date = null): array
{
$date = $date ?: now();
if (!$this->isLeader()) {
return [];
}
/** @var array<int, Prevention> */
$preventions = [];

View File

@ -4,6 +4,7 @@ namespace Database\Factories\Form\Models;
use App\Form\Data\ExportData;
use App\Form\Models\Form;
use App\Lib\Editor\Condition;
use Database\Factories\Traits\FakesMedia;
use Illuminate\Database\Eloquent\Factories\Factory;
use Tests\Feature\Form\FormtemplateFieldRequest;
@ -54,6 +55,7 @@ class FormFactory extends Factory
'is_active' => true,
'is_private' => false,
'export' => ExportData::from([]),
'prevention_conditions' => Condition::defaults(),
];
}

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('forms', function (Blueprint $table) {
$table->json('prevention_conditions')->default(json_encode(['mode' => 'all', 'ifs' => []]));
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('forms', function (Blueprint $table) {
$table->dropColumn('prevention_conditions');
});
}
};

View File

@ -5,68 +5,72 @@
</ui-note>
<div v-else>
<f-select id="mode" v-model="inner.mode" :options="modeOptions" name="mode" label="Modus"></f-select>
<f-select :id="`mode-${id}`" :name="`mode-${id}`" :model-value="modelValue.mode" :options="modeOptions" label="Modus" @update:model-value="changeMode"></f-select>
<ui-icon-button class="mt-4 mb-2" icon="plus" @click="addCondition">Bedingung einfügen</ui-icon-button>
<div v-for="(condition, index) in inner.ifs" :key="index" class="grid grid-cols-[1fr_1fr_1fr_max-content] gap-2">
<div v-for="(condition, index) in modelValue.ifs" :key="index" class="grid grid-cols-[1fr_1fr_1fr_max-content] gap-2">
<f-select
:id="`field-${index}`"
:id="`field-${index}-${id}`"
:model-value="condition.field"
:options="fieldOptions"
:name="`field-${index}`"
:name="`field-${index}-${id}`"
label="Feld"
@update:model-value="update(index, 'field', $event)"
></f-select>
<f-select
:id="`comparator-${index}`"
:id="`comparator-${index}-${id}`"
:options="comparatorOptions"
:model-value="condition.comparator"
:name="`comparator-${index}`"
:name="`comparator-${index}-${id}`"
label="Vergleich"
@update:model-value="update(index, 'comparator', $event)"
></f-select>
<f-select
v-if="condition.field && ['isEqual', 'isNotEqual'].includes(condition.comparator) && ['RadioField', 'DropdownField'].includes(getField(condition.field).type)"
:id="`value-${index}`"
:id="`value-${index}-${id}`"
v-model="condition.value"
:options="getOptions(condition.field)"
:name="`value-${index}`"
:name="`value-${index}-${id}`"
label="Wert"
></f-select>
<f-multipleselect
v-if="condition.field && ['isIn', 'isNotIn'].includes(condition.comparator) && ['RadioField', 'DropdownField'].includes(getField(condition.field).type)"
:id="`value-${index}`"
:id="`value-${index}-${id}`"
v-model="condition.value"
:options="getOptions(condition.field)"
label="Wert"
></f-multipleselect>
<f-switch
v-if="condition.field && condition.comparator && ['CheckboxField'].includes(getField(condition.field).type)"
:id="`value-${index}`"
:id="`value-${index}-${id}`"
v-model="condition.value"
:name="`value-${index}`"
:name="`value-${index}-${id}`"
label="Wert"
></f-switch>
<ui-action-button tooltip="Löschen" icon="trash" class="btn-danger self-end h-8" @click="inner.ifs.splice(index, 1)"></ui-action-button>
<ui-action-button tooltip="Löschen" icon="trash" class="btn-danger self-end h-8" @click="remove(index)"></ui-action-button>
</div>
<ui-icon-button class="mt-4 mb-2" icon="save" @click="save">Speichern</ui-icon-button>
</div>
</template>
<script lang="js" setup>
import { ref, inject, computed } from 'vue';
const axios = inject('axios');
const emit = defineEmits(['save']);
const emit = defineEmits(['update:modelValue']);
const props = defineProps({
value: {
modelValue: {
required: true,
type: Object,
},
single: {
required: true,
type: Object,
},
id: {
required: true,
type: String,
}
});
const comparatorOptions = ref([
@ -94,23 +98,33 @@ const fields = computed(() => {
return result;
});
function changeMode(mode) {
emit('update:modelValue', {...props.modelValue, mode: mode});
}
function update(index, key, value) {
var inner = {...props.modelValue};
if (key === 'comparator') {
var old = inner.value.ifs[index];
inner.value.ifs[index] = {
var old = inner.ifs[index];
inner.ifs[index] = {
field: old.field,
comparator: value,
value: old.field ? comparatorOptions.value.find((c) => c.id === value).defaultValue[getField(old.field).type] : null,
};
}
if (key === 'field') {
var old = inner.value.ifs[index];
inner.value.ifs[index] = {
var old = inner.ifs[index];
inner.ifs[index] = {
field: value,
comparator: null,
value: null,
};
}
emit('update:modelValue', inner);
}
function remove(index) {
emit('update:modelValue', {...props.modelValue, ifs: props.modelValue.ifs.toSpliced(index, 1)});
}
function getField(fieldName) {
@ -129,22 +143,19 @@ const fieldOptions = computed(() =>
})
);
const inner = ref(JSON.parse(JSON.stringify(props.value)));
function addCondition() {
emit('update:modelValue', {...props.modelValue, ifs: [
...props.modelValue.ifs,
{
field: null,
comparator: null,
value: null,
}
]});
}
const locked = ref(false);
function addCondition() {
inner.value.ifs.push({
field: null,
comparator: null,
value: null,
});
}
async function save() {
emit('save', inner.value);
}
async function checkIfDirty() {
const response = await axios.post(props.single.links.is_dirty, { config: props.single.config });

View File

@ -0,0 +1,31 @@
<template>
<div>
<conditions :id="id" v-model="inner" :single="single"></conditions>
<ui-icon-button class="mt-4 mb-2" icon="save" @click="save">Speichern</ui-icon-button>
</div>
</template>
<script lang="js" setup>
import { ref, inject, computed } from 'vue';
const emit = defineEmits(['save']);
import Conditions from './Conditions.vue';
const props = defineProps({
value: {
required: true,
},
single: {
required: true,
},
id: {
required: true,
type: String,
}
});
const inner = ref(JSON.parse(JSON.stringify(props.value)));
async function save() {
emit('save', inner.value);
}
</script>

View File

@ -81,12 +81,12 @@
<ui-tabs v-model="activeMailTab" :entries="mailTabs"></ui-tabs>
<f-editor v-if="activeMailTab === 0" id="mail_top" v-model="single.mail_top" name="mail_top" label="E-Mail-Teil 1" :rows="8" conditions required>
<template #conditions="{data, resolve}">
<conditions :single="single" :value="data" @save="resolve"> </conditions>
<conditions-form id="mail_top_conditions" :single="single" :value="data" @save="resolve"> </conditions-form>
</template>
</f-editor>
<f-editor v-if="activeMailTab === 1" id="mail_bottom" v-model="single.mail_bottom" name="mail_bottom" label="E-Mail-Teil 2" :rows="8" conditions required>
<template #conditions="{data, resolve}">
<conditions :single="single" :value="data" @save="resolve"> </conditions>
<conditions-form id="mail_bottom_conditions" :single="single" :value="data" @save="resolve"> </conditions-form>
</template>
</f-editor>
</div>
@ -124,6 +124,9 @@
:rows="6"
label="Präventions-Hinweis"
></f-editor>
<ui-box heading="Bedingung für Präventions-Unterlagen">
<conditions id="prevention_conditions" v-model="single.prevention_conditions" :single="single"> </conditions>
</ui-box>
</div>
</div>
<template #actions>
@ -134,7 +137,7 @@
</ui-popup>
<ui-popup v-if="fileSettingPopup !== null" :heading="`Bedingungen für Datei ${fileSettingPopup.name}`" @close="fileSettingPopup = null">
<conditions :single="single" :value="fileSettingPopup.properties.conditions" @save="saveFileConditions"> </conditions>
<conditions-form id="filesettings" :single="single" :value="fileSettingPopup.properties.conditions" @save="saveFileConditions"> </conditions-form>
</ui-popup>
<page-filter breakpoint="xl">
@ -188,6 +191,7 @@ import { indexProps, useIndex } from '../../composables/useInertiaApiIndex.js';
import FormBuilder from '../formtemplate/FormBuilder.vue';
import Participants from './Participants.vue';
import Conditions from './Conditions.vue';
import ConditionsForm from './ConditionsForm.vue';
import { useToast } from 'vue-toastification';
const props = defineProps(indexProps);

View File

@ -61,6 +61,7 @@ class FormIndexActionTest extends FormTestCase
->assertInertiaPath('data.meta.templates.0.name', 'tname')
->assertInertiaPath('data.meta.templates.0.config.sections.0.name', 'sname')
->assertInertiaPath('data.meta.default.name', '')
->assertInertiaPath('data.meta.default.prevention_conditions', ['mode' => 'all', 'ifs' => []])
->assertInertiaPath('data.meta.default.prevention_text.version', '1.0')
->assertInertiaPath('data.meta.default.description', [])
->assertInertiaPath('data.meta.default.excerpt', '')

View File

@ -3,6 +3,7 @@
namespace Tests\Feature\Form;
use App\Form\Data\ExportData;
use App\Lib\Editor\Condition;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\RequestFactories\EditorRequestFactory;
@ -52,6 +53,7 @@ class FormRequest extends RequestFactory
'export' => ExportData::from([])->toArray(),
'needs_prevention' => $this->faker->boolean(),
'prevention_text' => EditorRequestFactory::new()->create(),
'prevention_conditions' => Condition::defaults(),
];
}

View File

@ -5,6 +5,7 @@ namespace Tests\Feature\Form;
use App\Fileshare\Data\FileshareResourceData;
use App\Form\Data\ExportData;
use App\Form\Models\Form;
use App\Lib\Editor\Condition;
use App\Lib\Editor\EditorData;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\RequestFactories\EditorRequestFactory;
@ -132,11 +133,12 @@ class FormUpdateActionTest extends FormTestCase
$form = Form::factory()->create();
$payload = FormRequest::new()
->preventionText(EditorRequestFactory::new()->text(10, 'lorem ipsum'))
->state(['needs_prevention' => true])
->state(['needs_prevention' => true, 'prevention_conditions' => ['mode' => 'all', 'ifs' => [['field' => 'vorname', 'value' => 'Max', 'comparator' => 'isEqual']]]])
->create();
$this->patchJson(route('form.update', ['form' => $form]), $payload);
$this->assertTrue($form->fresh()->needs_prevention);
$this->assertEquals('lorem ipsum', $form->fresh()->prevention_text->blocks[0]['data']['text']);
$this->assertEquals(['mode' => 'all', 'ifs' => [['field' => 'vorname', 'value' => 'Max', 'comparator' => 'isEqual']]], $form->fresh()->prevention_conditions->toArray());
}
}

View File

@ -9,6 +9,7 @@ use App\Form\Enums\SpecialType;
use App\Form\Models\Form;
use App\Form\Models\Participant;
use App\Invoice\InvoiceSettings;
use App\Lib\Editor\Condition;
use App\Prevention\Mails\PreventionRememberMail;
use App\Member\Member;
use App\Member\Membership;
@ -37,6 +38,19 @@ class PreventionTest extends TestCase
$this->assertEquals(now()->format('Y-m-d'), $participant->fresh()->last_remembered_at->format('Y-m-d'));
}
public function testItDoesntRememberWhenConditionDoesntMatch(): void
{
Mail::fake();
$form = $this->createForm();
$form->update(['prevention_conditions' => Condition::from(['mode' => 'all', 'ifs' => [['field' => 'vorname', 'comparator' => 'isEqual', 'value' => 'Max']]])]);
$participant = $this->createParticipant($form);
$participant->update(['data' => [...$participant->data, 'vorname' => 'Jane']]);
PreventionRememberAction::run();
$this->assertNull($participant->fresh()->last_remembered_at);
}
public function testItRemembersWhenRememberIsDue(): void
{
Mail::fake();
@ -82,7 +96,7 @@ class PreventionTest extends TestCase
$this->assertNull($participant->fresh()->last_remembered_at);
}
public function testItDoesntRememberWhenMemberIsNotALeader(): void
public function testItRemembersNonLeaders(): void
{
Mail::fake();
$form = $this->createForm();
@ -91,7 +105,7 @@ class PreventionTest extends TestCase
PreventionRememberAction::run();
$this->assertNull($participant->fresh()->last_remembered_at);
$this->assertNotNull($participant->fresh()->last_remembered_at);
}
protected function attributes(): Generator