Add filter for participants
continuous-integration/drone/push Build is failing Details

This commit is contained in:
philipp lang 2024-04-26 23:20:03 +02:00
parent 9954ba1ee4
commit 8f285b4aa6
7 changed files with 257 additions and 79 deletions

View File

@ -4,6 +4,7 @@ namespace App\Form\Resources;
use App\Form\Models\Form;
use App\Form\Models\Participant;
use App\Form\Scopes\ParticipantFilterScope;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
@ -43,7 +44,17 @@ class ParticipantResource extends JsonResource
'id' => $field->key,
'display_attribute' => $field->getDisplayAttribute(),
]);
$filterData = $form->getFields()
->map(fn ($field) => [
...$field->toArray(),
'base_type' => class_basename($field),
]);
return [
'filter' => ParticipantFilterScope::fromRequest(request()->input('filter', ''))->setForm($form),
'default_filter_value' => ParticipantFilterScope::$nan,
'filters' => $filterData,
'form_meta' => $form->meta,
'links' => [
'update_form_meta' => route('form.update-meta', ['form' => $form]),

View File

@ -2,9 +2,11 @@
namespace App\Form\Scopes;
use App\Form\Models\Form;
use App\Form\Models\Participant;
use App\Lib\Filter;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
use Spatie\LaravelData\Attributes\MapInputName;
use Spatie\LaravelData\Attributes\MapOutputName;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
@ -18,9 +20,23 @@ class ParticipantFilterScope extends Filter
{
public function __construct(
public ?int $parent = null,
public array $data = [],
) {
}
public static string $nan = 'deeb3ef4-d185-44b1-a4bc-0a4e7addebc3d8900c6f-a344-4afb-b54e-065ed483a7ba';
public function setForm(Form $form): self
{
foreach ($form->getFields() as $field) {
if (!Arr::has($this->data, $field->key)) {
data_set($this->data, $field->key, static::$nan);
}
}
return $this;
}
/**
* @inheritdoc
*/
@ -34,6 +50,13 @@ class ParticipantFilterScope extends Filter
$query = $query->where('parent_id', $this->parent);
}
foreach ($this->data as $key => $value) {
if ($value === static::$nan) {
continue;
}
$query = $query->where('data->' . $key, $value);
}
return $query;
}
}

View File

@ -5,8 +5,8 @@
<span v-show="required" class="text-red-800">&nbsp;*</span>
</span>
<div class="real-field-wrap" :class="`size-${size}`">
<select :disabled="disabled" :name="name" :value="modelValue" @change="trigger">
<option v-if="placeholder" value="">{{ placeholder }}</option>
<select v-model="inner" :disabled="disabled" :name="name">
<option v-if="placeholder" :value="def">{{ placeholder }}</option>
<option v-for="option in parsedOptions" :key="option.id" :value="option.id">{{ option.name }}</option>
</select>
@ -25,84 +25,83 @@
</label>
</template>
<script>
<script setup>
import {computed, ref} from 'vue';
import map from 'lodash/map';
export default {
props: {
disabled: {
type: Boolean,
default: function () {
return false;
},
},
id: {},
inset: {
type: Boolean,
default: false,
},
size: {
default: function () {
return 'base';
},
},
emptyLabel: {
default: false,
type: Boolean,
},
modelValue: {
default: undefined,
},
label: {
default: null,
},
required: {
type: Boolean,
default: false,
},
placeholder: {
default: '--kein--',
type: String,
},
def: {
required: false,
type: Number,
default: -1,
},
name: {
required: true,
},
hint: {},
options: {
default: function () {
return [];
},
},
},
emits: ['update:modelValue'],
computed: {
parsedOptions() {
return Array.isArray(this.options)
? this.options
: map(this.options, (value, key) => {
return { name: value, id: key };
});
},
},
mounted() {
if (this.def !== -1 && typeof this.modelValue === 'undefined') {
this.$emit('update:modelValue', this.def);
return;
}
const emit = defineEmits(['update:modelValue']);
if (this.placeholder && typeof this.modelValue === 'undefined') {
this.$emit('update:modelValue', null);
}
const props = defineProps({
nullValue: {
required: false,
default: () => null,
},
methods: {
trigger(v) {
this.$emit('update:modelValue', /^[0-9]+$/.test(v.target.value) ? parseInt(v.target.value) : v.target.value ? v.target.value : null);
disabled: {
type: Boolean,
default: function () {
return false;
},
},
};
id: {},
inset: {
type: Boolean,
default: false,
},
size: {
default: function () {
return 'base';
},
},
emptyLabel: {
default: false,
type: Boolean,
},
modelValue: {
default: undefined,
},
label: {
default: null,
},
required: {
type: Boolean,
default: false,
},
placeholder: {
default: '--kein--',
type: String,
},
def: {
required: false,
type: Number,
default: -1,
},
name: {
required: true,
},
hint: {},
options: {
default: function () {
return [];
},
},
});
const parsedOptions = computed(() => {
return Array.isArray(props.options)
? props.options
: map(props.options, (value, key) => {
return {name: value, id: key};
});
});
const def = ref('iu1Feixah5AeKai3ewooJahjeaegee0eiD4maeth1oul4Hei7u');
const inner = computed({
get: () => {
return props.modelValue === props.nullValue ? def.value : props.modelValue;
},
set: (v) => {
emit('update:modelValue', v === def.value ? props.nullValue : v);
},
});
</script>

View File

@ -11,9 +11,10 @@ export function useApiIndex(url, siteName) {
meta: ref({}),
};
async function reload(resetPage = true) {
async function reload(resetPage = true, p = {}) {
var params = {
page: resetPage ? 1 : inner.meta.value.current_page,
...p,
};
var response = (await axios.get(url, {params})).data;
@ -49,6 +50,10 @@ export function useApiIndex(url, siteName) {
return inner.meta.value.can[permission];
}
function toFilterString(data) {
return btoa(encodeURIComponent(JSON.stringify(data)));
}
function requestCallback(successMessage, failureMessage) {
return {
onSuccess: () => {
@ -85,5 +90,6 @@ export function useApiIndex(url, siteName) {
remove,
cancel,
axios,
toFilterString,
};
}

View File

@ -11,6 +11,39 @@
</ui-popup>
<page-filter breakpoint="lg">
<f-multipleselect id="active_columns" v-model="activeColumnsConfig" :options="meta.columns" label="Aktive Spalten" size="sm" name="active_columns"></f-multipleselect>
<template v-for="(filter, index) in meta.filters">
<f-select
v-if="filter.base_type === 'CheckboxField'"
:id="`filter-field-${index}`"
:key="`filter-field-${index}`"
v-model="innerFilter.data[filter.key]"
:null-value="meta.default_filter_value"
:name="`filter-field-${index}`"
:options="checkboxFilterOptions"
:label="filter.name"
></f-select>
<f-select
v-if="filter.base_type === 'DropdownField'"
:id="`filter-field-${index}`"
:key="`filter-field-${index}`"
v-model="innerFilter.data[filter.key]"
:null-value="meta.default_filter_value"
:name="`filter-field-${index}`"
:options="dropdownFilterOptions(filter)"
:label="filter.name"
></f-select>
<f-select
v-if="filter.base_type === 'RadioField'"
:id="`filter-field-${index}`"
:key="`filter-field-${index}`"
v-model="innerFilter.data[filter.key]"
:null-value="meta.default_filter_value"
:name="`filter-field-${index}`"
:options="dropdownFilterOptions(filter)"
:label="filter.name"
></f-select>
</template>
</page-filter>
<table cellspacing="0" cellpadding="0" border="0" class="custom-table custom-table-sm">
<thead>
@ -35,7 +68,7 @@
</template>
<script setup>
import {ref, computed} from 'vue';
import {watch, ref, computed} from 'vue';
import {useApiIndex} from '../../composables/useApiIndex.js';
const deleting = ref(null);
@ -48,7 +81,7 @@ const props = defineProps({
},
});
var {meta, data, reload, reloadPage, axios, remove} = useApiIndex(props.url, 'participant');
var {meta, data, reload, reloadPage, axios, remove, toFilterString} = useApiIndex(props.url, 'participant');
const activeColumns = computed(() => meta.value.columns.filter((c) => meta.value.form_meta.active_columns.includes(c.id)));
@ -70,4 +103,30 @@ async function handleDelete() {
}
await reload();
const innerFilter = ref(JSON.parse(JSON.stringify(meta.value.filter)));
watch(
innerFilter,
async function (newValue) {
await reload(true, {
filter: toFilterString(newValue),
});
},
{deep: true}
);
const checkboxFilterOptions = ref([
{id: true, name: 'Ja'},
{id: false, name: 'Nein'},
]);
function dropdownFilterOptions(filter) {
return [
{id: null, name: 'keine Auswahl'},
...filter.options.map((f) => {
return {id: f, name: f};
}),
];
}
</script>

View File

@ -2,9 +2,11 @@
namespace Tests\Feature\Form;
use App\Form\Fields\CheckboxField;
use App\Form\Fields\TextField;
use App\Form\Models\Form;
use App\Form\Models\Participant;
use App\Form\Scopes\ParticipantFilterScope;
use App\Group;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\DatabaseTransactions;
@ -56,6 +58,75 @@ class ParticipantIndexActionTest extends FormTestCase
->assertJsonPath('meta.form_meta.sorting', ['vorname', 'asc']);
}
public function testItShowsEmptyFilters(): void
{
$this->login()->loginNami()->withoutExceptionHandling();
$form = Form::factory()->fields([$this->checkboxField('check')->name('Checked')])->create();
$this->callFilter('form.participant.index', [], ['form' => $form])
->assertOk()
->assertJsonPath('meta.filters.0.name', 'Checked')
->assertJsonPath('meta.filters.0.key', 'check')
->assertJsonPath('meta.filters.0.base_type', 'CheckboxField')
->assertJsonPath('meta.default_filter_value', ParticipantFilterScope::$nan);
$this->callFilter('form.participant.index', ['data' => ['check' => null]], ['form' => $form])->assertHasJsonPath('meta.filter.data.check')->assertJsonPath('meta.filter.data.check', null);
$this->callFilter('form.participant.index', ['data' => ['check' => 'A']], ['form' => $form])->assertJsonPath('meta.filter.data.check', 'A');
$this->callFilter('form.participant.index', ['data' => []], ['form' => $form])->assertJsonPath('meta.filter.data.check', ParticipantFilterScope::$nan);
}
public function testItFiltersParticipantsByCheckboxValue(): void
{
$this->login()->loginNami()->withoutExceptionHandling();
$form = Form::factory()->fields([$this->checkboxField('check')])
->has(Participant::factory()->data(['check' => true])->count(1))
->has(Participant::factory()->data(['check' => false])->count(2))
->create();
$this->callFilter('form.participant.index', ['data' => ['check' => ParticipantFilterScope::$nan]], ['form' => $form])
->assertJsonCount(3, 'data');
$this->callFilter('form.participant.index', ['data' => ['check' => true]], ['form' => $form])
->assertJsonCount(1, 'data');
$this->callFilter('form.participant.index', ['data' => ['check' => false]], ['form' => $form])
->assertJsonCount(2, 'data');
}
public function testItFiltersParticipantsByDropdownValue(): void
{
$this->login()->loginNami()->withoutExceptionHandling();
$form = Form::factory()->fields([$this->dropdownField('drop')->options(['A', 'B'])])
->has(Participant::factory()->data(['drop' => null])->count(1))
->has(Participant::factory()->data(['drop' => 'A'])->count(2))
->has(Participant::factory()->data(['drop' => 'B'])->count(4))
->create();
$this->callFilter('form.participant.index', ['data' => ['drop' => ParticipantFilterScope::$nan]], ['form' => $form])
->assertJsonCount(7, 'data');
$this->callFilter('form.participant.index', ['data' => ['drop' => null]], ['form' => $form])
->assertJsonCount(1, 'data');
$this->callFilter('form.participant.index', ['data' => ['drop' => 'A']], ['form' => $form])
->assertJsonCount(2, 'data');
$this->callFilter('form.participant.index', ['data' => ['drop' => 'B']], ['form' => $form])
->assertJsonCount(4, 'data');
}
public function testItFiltersParticipantsByRadioValue(): void
{
$this->login()->loginNami()->withoutExceptionHandling();
$form = Form::factory()->fields([$this->radioField('drop')->options(['A', 'B'])])
->has(Participant::factory()->data(['drop' => null])->count(1))
->has(Participant::factory()->data(['drop' => 'A'])->count(2))
->has(Participant::factory()->data(['drop' => 'B'])->count(4))
->create();
$this->callFilter('form.participant.index', ['data' => ['drop' => ParticipantFilterScope::$nan]], ['form' => $form])
->assertJsonCount(7, 'data');
$this->callFilter('form.participant.index', ['data' => ['drop' => 'A']], ['form' => $form])
->assertJsonCount(2, 'data');
$this->callFilter('form.participant.index', ['data' => ['drop' => 'B']], ['form' => $form])
->assertJsonCount(4, 'data');
}
public function testItShowsOnlyRootMembers(): void
{
$this->login()->loginNami()->withoutExceptionHandling();

View File

@ -8,6 +8,7 @@ use App\Setting\NamiSettings;
use App\User;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Testing\AssertableJsonString;
use Illuminate\Testing\TestResponse;
@ -153,5 +154,13 @@ abstract class TestCase extends BaseTestCase
return $this;
});
TestResponse::macro('assertHasJsonPath', function (string $path) {
/** @var TestResponse */
$response = $this;
Assert::assertTrue(Arr::has($response->json(), $path), 'Failed that key ' . $path . ' is in Response.');
return $this;
});
}
}