Snapshot of main at 9f39a6e1e (2026-11-02)

This commit is contained in:
Ronald Huynen committed 2026-11-02 10:41:47 +01:00
commit 6ccf1792d7
2580 files changed
+1040202

No files matched your search

+87
View File
@@ -0,0 +1,87 @@
<?php
namespace App\Polls\Types;
use App\Polls\Contracts\QuestionType;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
/**
* Shared defaults: no settings, a free-text answer passed through as is,
* and partials in resources/views/polls/types/{key}/.
*/
abstract class AbstractQuestionType implements QuestionType
{
public function defaultSettings(): array
{
return [];
}
public function settingsRules(string $prefix, array $settings): array
{
return [];
}
public function newListItem(string $list): array
{
return ['key' => Str::lower(Str::random(8))];
}
public function normalize(array $answer, array $settings): array
{
return [
'value' => $answer['value'] ?? null,
'text' => $this->cleanText($answer['text'] ?? null),
];
}
public function answerToText(mixed $value, ?string $text, array $settings, ?string $locale = null): string
{
$parts = array_filter([is_scalar($value) ? (string) $value : null, $text], fn ($part) => $part !== null && $part !== '');
return implode(': ', $parts);
}
public function editorView(): string
{
return 'polls.types.' . $this->key() . '.editor';
}
public function inputView(): string
{
return 'polls.types.' . $this->key() . '.input';
}
public function resultsView(): string
{
return 'polls.types.' . $this->key() . '.results';
}
/**
* Trimmed text, or null when empty.
*/
protected function cleanText($text): ?string
{
$text = is_string($text) ? trim($text) : null;
return $text === '' ? null : $text;
}
/**
* Answers that hold a value or a text (optional questions can be left empty).
*/
protected function answered(Collection $answers): Collection
{
return $answers->filter(fn ($answer) => $answer->value !== null || $answer->text !== null)->values();
}
public function answerMessages(string $prefix, array $settings): array
{
return [];
}
protected function requiredRule(bool $required): string
{
return $required ? 'required' : 'nullable';
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace App\Polls\Types\Concerns;
use App\Polls\Localized;
/**
* Question types with a list of options: [['key' => 'abc', 'label' => ['en' => '...']], ...]
* and an optional "Other: ..." choice.
*/
trait HasOptions
{
public const OTHER = '__other';
protected function defaultOptions(): array
{
return [$this->newListItem('options') + ['label' => []], $this->newListItem('options') + ['label' => []]];
}
protected function optionsRules(string $prefix, int $min = 2): array
{
return [
"$prefix.options" => ['required', 'array', "min:$min", 'max:30'],
"$prefix.options.*.key" => ['required', 'string', 'max:20', 'distinct', 'not_in:' . self::OTHER],
"$prefix.options.*.label" => ['required', 'array', Localized::filledRule()],
"$prefix.options.*.label.*" => ['nullable', 'string', 'max:200'],
];
}
/** @return array<int, string> */
public function optionKeys(array $settings, bool $withOther = false): array
{
$keys = array_column($settings['options'] ?? [], 'key');
if ($withOther && !empty($settings['allow_other'])) {
$keys[] = self::OTHER;
}
return $keys;
}
/** @return array<string, string> key => label in the given locale, "Other" last when allowed */
public function optionLabels(array $settings, ?string $locale = null, bool $withOther = false): array
{
$labels = [];
foreach ($settings['options'] ?? [] as $option) {
$labels[$option['key']] = Localized::pick($option['label'] ?? [], $locale);
}
if ($withOther && !empty($settings['allow_other'])) {
$labels[self::OTHER] = __('Other');
}
return $labels;
}
}
+159
View File
@@ -0,0 +1,159 @@
<?php
namespace App\Polls\Types;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Validation\Rule;
/**
* Doodle-style availability: the organizer proposes date/time slots,
* the voter marks each slot yes / if needed / no.
*/
class DateTimeSlotsType extends AbstractQuestionType
{
public const YES = 'yes';
public const IF_NEEDED = 'if_needed';
public const NO = 'no';
public function key(): string
{
return 'date_time_slots';
}
public function label(): string
{
return __('Date & time availability');
}
public function defaultSettings(): array
{
return ['slots' => [$this->newListItem('slots')], 'allow_if_needed' => true];
}
public function newListItem(string $list): array
{
return parent::newListItem($list) + ['starts_at' => null, 'ends_at' => null];
}
public function settingsRules(string $prefix, array $settings): array
{
return [
"$prefix.slots" => ['required', 'array', 'min:1', 'max:30'],
"$prefix.slots.*.key" => ['required', 'string', 'max:20', 'distinct'],
"$prefix.slots.*.starts_at" => ['required', 'date'],
"$prefix.slots.*.ends_at" => ['nullable', 'date', "after:$prefix.slots.*.starts_at"],
"$prefix.allow_if_needed" => ['boolean'],
];
}
/** @return array<string, string> availability key => label */
public function choices(array $settings): array
{
$choices = [self::YES => __('Yes')];
if (!empty($settings['allow_if_needed'])) {
$choices[self::IF_NEEDED] = __('If needed');
}
$choices[self::NO] = __('No');
return $choices;
}
/** @return array<string, string> slot key => formatted date/time, in slot order */
public function slotLabels(array $settings, ?string $locale = null): array
{
$locale = $locale ?? app()->getLocale();
$labels = [];
foreach ($settings['slots'] ?? [] as $slot) {
if (empty($slot['starts_at'])) {
continue;
}
$start = Carbon::parse($slot['starts_at'])->locale($locale);
$label = $start->isoFormat('dddd D MMMM YYYY, HH:mm');
if (!empty($slot['ends_at'])) {
$end = Carbon::parse($slot['ends_at'])->locale($locale);
$label .= ' – ' . ($end->isSameDay($start) ? $end->isoFormat('HH:mm') : $end->isoFormat('dddd D MMMM YYYY, HH:mm'));
}
$labels[$slot['key']] = $label;
}
return $labels;
}
public function answerRules(string $prefix, array $settings, bool $required): array
{
$rules = ["$prefix.value" => [$this->requiredRule($required), 'array']];
foreach (array_column($settings['slots'] ?? [], 'key') as $key) {
$rules["$prefix.value.$key"] = [$this->requiredRule($required), Rule::in(array_keys($this->choices($settings)))];
}
return $rules;
}
public function answerMessages(string $prefix, array $settings): array
{
// One message per date; the slot keys are random strings, not field names
return [
"$prefix.value.*.required" => __('Choose an answer for this date.'),
"$prefix.value.*.in" => __('Choose an answer for this date.'),
];
}
public function normalize(array $answer, array $settings): array
{
$keys = array_column($settings['slots'] ?? [], 'key');
$value = array_filter(
array_intersect_key((array) ($answer['value'] ?? []), array_flip($keys)),
fn ($availability) => $availability !== null && $availability !== ''
);
return ['value' => $value ?: null, 'text' => null];
}
public function answerToText(mixed $value, ?string $text, array $settings, ?string $locale = null): string
{
$choices = $this->choices($settings);
$parts = [];
foreach ($this->slotLabels($settings, $locale) as $key => $label) {
if (isset(((array) $value)[$key])) {
$parts[] = $label . ': ' . ($choices[$value[$key]] ?? $value[$key]);
}
}
return implode('; ', $parts);
}
public function aggregate(Collection $answers, array $settings): array
{
$answered = $this->answered($answers);
$slots = [];
foreach (array_column($settings['slots'] ?? [], 'key') as $key) {
$slots[$key] = [self::YES => 0, self::IF_NEEDED => 0, self::NO => 0];
}
foreach ($answered as $answer) {
foreach ((array) $answer->value as $key => $availability) {
if (isset($slots[$key][$availability])) {
$slots[$key][$availability]++;
}
}
}
// Best slots: most "yes", then most "if needed"; none when nobody is available
$best = [];
$top = null;
foreach ($slots as $key => $counts) {
$score = [$counts[self::YES], $counts[self::IF_NEEDED]];
if ($score[0] + $score[1] === 0) {
continue;
}
if ($top === null || $score > $top) {
$top = $score;
$best = [$key];
} elseif ($score === $top) {
$best[] = $key;
}
}
return ['total' => $answered->count(), 'slots' => $slots, 'best' => $best];
}
}
+116
View File
@@ -0,0 +1,116 @@
<?php
namespace App\Polls\Types;
use App\Polls\Rules\RequiredWhenSelected;
use App\Polls\Types\Concerns\HasOptions;
use Illuminate\Support\Collection;
use Illuminate\Validation\Rule;
/**
* Pick several options (checkboxes) within min/max, optionally "Other: ...".
*/
class MultiChoiceType extends AbstractQuestionType
{
use HasOptions;
public function key(): string
{
return 'multi_choice';
}
public function label(): string
{
return __('Multi-select');
}
public function defaultSettings(): array
{
return ['options' => $this->defaultOptions(), 'allow_other' => false, 'min_selections' => null, 'max_selections' => null];
}
public function settingsRules(string $prefix, array $settings): array
{
$choiceCount = count($this->optionKeys($settings, true));
return $this->optionsRules($prefix) + [
"$prefix.allow_other" => ['boolean'],
"$prefix.min_selections" => ['nullable', 'integer', 'min:1', "max:$choiceCount"],
// Compared with the minimum only when one is set: gte fails against an empty field
"$prefix.max_selections" => array_merge(
['nullable', 'integer', 'min:1', "max:$choiceCount"],
($settings['min_selections'] ?? null) !== null ? ["gte:$prefix.min_selections"] : []
),
];
}
public function answerRules(string $prefix, array $settings, bool $required): array
{
$min = max(1, (int) ($settings['min_selections'] ?? 1));
$max = $settings['max_selections'] ?? null;
return [
"$prefix.value" => [
$this->requiredRule($required),
'array',
function (string $attribute, $value, \Closure $fail) use ($min, $max, $required) {
$count = is_array($value) ? count($value) : 0;
if ($count === 0 && !$required) {
return;
}
if ($count < $min) {
$fail(trans_choice('Select at least :count option.|Select at least :count options.', $min, ['count' => $min]));
} elseif ($max && $count > $max) {
$fail(trans_choice('Select at most :count option.|Select at most :count options.', $max, ['count' => $max]));
}
},
],
"$prefix.value.*" => ['distinct', Rule::in($this->optionKeys($settings, true))],
"$prefix.text" => [
'nullable',
'string',
'max:200',
new RequiredWhenSelected("$prefix.value", self::OTHER),
],
];
}
public function normalize(array $answer, array $settings): array
{
$value = array_values((array) ($answer['value'] ?? []));
return [
'value' => $value ?: null,
'text' => in_array(self::OTHER, $value, true) ? $this->cleanText($answer['text'] ?? null) : null,
];
}
public function answerToText(mixed $value, ?string $text, array $settings, ?string $locale = null): string
{
$labels = $this->optionLabels($settings, $locale, true);
return collect((array) $value)
->map(fn ($key) => $key === self::OTHER && $text ? $labels[$key] . ': ' . $text : ($labels[$key] ?? $key))
->implode('; ');
}
public function aggregate(Collection $answers, array $settings): array
{
$answered = $this->answered($answers);
$counts = array_fill_keys($this->optionKeys($settings, true), 0);
foreach ($answered as $answer) {
foreach (array_unique((array) $answer->value) as $key) {
if (isset($counts[$key])) {
$counts[$key]++;
}
}
}
return [
'total' => $answered->count(),
'counts' => $counts,
'other_texts' => $answered->filter(fn ($answer) => in_array(self::OTHER, (array) $answer->value, true))
->pluck('text')->filter()->values()->all(),
];
}
}
+111
View File
@@ -0,0 +1,111 @@
<?php
namespace App\Polls\Types;
use App\Polls\Types\Concerns\HasOptions;
use Illuminate\Support\Collection;
use Illuminate\Validation\Rule;
/**
* Put the options in order of preference, optionally only the top N.
* Results use a Borda count: with n options, 1st place gets n points, 2nd n-1, ...
*/
class RankingType extends AbstractQuestionType
{
use HasOptions;
public function key(): string
{
return 'ranking';
}
public function label(): string
{
return __('Ranking');
}
public function defaultSettings(): array
{
return ['options' => $this->defaultOptions(), 'top_n' => null];
}
public function settingsRules(string $prefix, array $settings): array
{
$optionCount = max(2, count($this->optionKeys($settings)));
return $this->optionsRules($prefix) + [
"$prefix.top_n" => ['nullable', 'integer', 'min:1', "max:$optionCount"],
];
}
/** Number of options a voter ranks. */
public function rankCount(array $settings): int
{
$optionCount = count($this->optionKeys($settings));
$topN = (int) ($settings['top_n'] ?? 0);
return $topN > 0 ? min($topN, $optionCount) : $optionCount;
}
public function answerRules(string $prefix, array $settings, bool $required): array
{
$rankCount = $this->rankCount($settings);
return [
"$prefix.value" => [
$this->requiredRule($required),
'array',
function (string $attribute, $value, \Closure $fail) use ($rankCount, $required) {
$count = is_array($value) ? count($value) : 0;
if ($count === 0 && !$required) {
return;
}
if ($count !== $rankCount) {
$fail(trans_choice('Rank :count option.|Rank :count options.', $rankCount, ['count' => $rankCount]));
}
},
],
"$prefix.value.*" => ['distinct', Rule::in($this->optionKeys($settings))],
];
}
public function normalize(array $answer, array $settings): array
{
$value = array_values((array) ($answer['value'] ?? []));
return ['value' => $value ?: null, 'text' => null];
}
public function answerToText(mixed $value, ?string $text, array $settings, ?string $locale = null): string
{
$labels = $this->optionLabels($settings, $locale);
return collect(array_values((array) $value))
->map(fn ($key, $position) => ($position + 1) . '. ' . ($labels[$key] ?? $key))
->implode('; ');
}
public function aggregate(Collection $answers, array $settings): array
{
$answered = $this->answered($answers);
$keys = $this->optionKeys($settings);
$optionCount = count($keys);
$points = array_fill_keys($keys, 0);
$firstPlace = array_fill_keys($keys, 0);
foreach ($answered as $answer) {
foreach (array_values((array) $answer->value) as $position => $key) {
if (isset($points[$key])) {
$points[$key] += $optionCount - $position;
}
}
$first = ((array) $answer->value)[0] ?? null;
if (isset($firstPlace[$first])) {
$firstPlace[$first]++;
}
}
arsort($points);
return ['total' => $answered->count(), 'points' => $points, 'first_place' => $firstPlace];
}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
namespace App\Polls\Types;
use Illuminate\Support\Collection;
/**
* Rating on a numeric scale (e.g. 1-5) with optional end labels.
*/
class ScaleType extends AbstractQuestionType
{
public function key(): string
{
return 'scale';
}
public function label(): string
{
return __('Rating scale');
}
public function defaultSettings(): array
{
return ['min' => 1, 'max' => 5, 'min_label' => [], 'max_label' => []];
}
public function settingsRules(string $prefix, array $settings): array
{
return [
"$prefix.min" => ['required', 'integer', 'in:0,1'],
"$prefix.max" => ['required', 'integer', 'min:2', 'max:10'],
"$prefix.min_label" => ['nullable', 'array'],
"$prefix.min_label.*" => ['nullable', 'string', 'max:50'],
"$prefix.max_label" => ['nullable', 'array'],
"$prefix.max_label.*" => ['nullable', 'string', 'max:50'],
];
}
/** @return array<int, int> */
public function steps(array $settings): array
{
return range((int) ($settings['min'] ?? 1), (int) ($settings['max'] ?? 5));
}
public function answerRules(string $prefix, array $settings, bool $required): array
{
$steps = $this->steps($settings);
return [
"$prefix.value" => [$this->requiredRule($required), 'integer', 'between:' . min($steps) . ',' . max($steps)],
];
}
public function normalize(array $answer, array $settings): array
{
$value = $answer['value'] ?? null;
return ['value' => ($value === null || $value === '') ? null : (int) $value, 'text' => null];
}
public function aggregate(Collection $answers, array $settings): array
{
$answered = $this->answered($answers);
$distribution = array_fill_keys($this->steps($settings), 0);
foreach ($answered as $answer) {
if (isset($distribution[$answer->value])) {
$distribution[$answer->value]++;
}
}
return [
'total' => $answered->count(),
'average' => $answered->isEmpty() ? null : round($answered->avg('value'), 1),
'distribution' => $distribution,
];
}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
namespace App\Polls\Types;
use App\Polls\Types\Concerns\HasOptions;
use Illuminate\Support\Collection;
use Illuminate\Validation\Rule;
/**
* Pick one option (radio list), optionally "Other: ...".
*/
class SingleChoiceType extends AbstractQuestionType
{
use HasOptions;
public function key(): string
{
return 'single_choice';
}
public function label(): string
{
return __('Single choice');
}
public function defaultSettings(): array
{
return ['options' => $this->defaultOptions(), 'allow_other' => false];
}
public function settingsRules(string $prefix, array $settings): array
{
return $this->optionsRules($prefix) + ["$prefix.allow_other" => ['boolean']];
}
public function answerRules(string $prefix, array $settings, bool $required): array
{
return [
"$prefix.value" => [$this->requiredRule($required), 'string', Rule::in($this->optionKeys($settings, true))],
"$prefix.text" => ['nullable', 'string', 'max:200', "required_if:$prefix.value," . self::OTHER],
];
}
public function normalize(array $answer, array $settings): array
{
$value = $answer['value'] ?? null;
return [
'value' => $value ?: null,
'text' => $value === self::OTHER ? $this->cleanText($answer['text'] ?? null) : null,
];
}
public function answerToText(mixed $value, ?string $text, array $settings, ?string $locale = null): string
{
$label = $this->optionLabels($settings, $locale, true)[$value] ?? (string) $value;
return $value === self::OTHER && $text ? "$label: $text" : $label;
}
public function aggregate(Collection $answers, array $settings): array
{
$answered = $this->answered($answers);
$counts = array_fill_keys($this->optionKeys($settings, true), 0);
foreach ($answered as $answer) {
if (isset($counts[$answer->value])) {
$counts[$answer->value]++;
}
}
return [
'total' => $answered->count(),
'counts' => $counts,
'other_texts' => $answered->where('value', self::OTHER)->pluck('text')->filter()->values()->all(),
];
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace App\Polls\Types;
use Illuminate\Support\Collection;
/**
* Open text answer, short (one line) or long.
*/
class TextType extends AbstractQuestionType
{
public function key(): string
{
return 'text';
}
public function label(): string
{
return __('Open text');
}
public function defaultSettings(): array
{
return ['multiline' => true, 'max_length' => 1000];
}
public function settingsRules(string $prefix, array $settings): array
{
return [
"$prefix.multiline" => ['boolean'],
"$prefix.max_length" => ['required', 'integer', 'min:10', 'max:5000'],
];
}
public function answerRules(string $prefix, array $settings, bool $required): array
{
return [
"$prefix.text" => [$this->requiredRule($required), 'string', 'max:' . (int) ($settings['max_length'] ?? 1000)],
];
}
public function normalize(array $answer, array $settings): array
{
return ['value' => null, 'text' => $this->cleanText($answer['text'] ?? null)];
}
public function aggregate(Collection $answers, array $settings): array
{
$answered = $this->answered($answers);
return ['total' => $answered->count(), 'texts' => $answered->pluck('text')->all()];
}
}
+101
View File
@@ -0,0 +1,101 @@
<?php
namespace App\Polls\Types;
use App\Polls\Localized;
use Illuminate\Support\Collection;
use Illuminate\Validation\Rule;
/**
* Yes / Maybe, if... (with a required condition text) / No.
*/
class YesNoConditionalType extends AbstractQuestionType
{
public const YES = 'yes';
public const MAYBE_IF = 'maybe_if';
public const NO = 'no';
public function key(): string
{
return 'yes_no_conditional';
}
public function label(): string
{
return __('Yes / No / Maybe, if...');
}
public function defaultSettings(): array
{
return ['conditional_label' => []];
}
public function settingsRules(string $prefix, array $settings): array
{
return [
"$prefix.conditional_label" => ['nullable', 'array'],
"$prefix.conditional_label.*" => ['nullable', 'string', 'max:100'],
];
}
/** @return array<string, string> answer key => label, in display order */
public function choices(array $settings, ?string $locale = null): array
{
$conditional = Localized::pick($settings['conditional_label'] ?? [], $locale);
return [
self::YES => __('Yes'),
self::MAYBE_IF => $conditional !== '' ? $conditional : __('Maybe, if...'),
self::NO => __('No'),
];
}
public function answerRules(string $prefix, array $settings, bool $required): array
{
return [
"$prefix.value" => [$this->requiredRule($required), 'string', Rule::in(array_keys($this->choices($settings)))],
"$prefix.text" => ['nullable', 'string', 'max:500', "required_if:$prefix.value," . self::MAYBE_IF],
];
}
public function answerMessages(string $prefix, array $settings): array
{
// The default message would name the stored value ("... is maybe_if")
return ["$prefix.text.required_if" => __('Please fill in the condition.')];
}
public function normalize(array $answer, array $settings): array
{
$value = $answer['value'] ?? null;
return [
'value' => $value ?: null,
// The condition only belongs to "Maybe, if..."
'text' => $value === self::MAYBE_IF ? $this->cleanText($answer['text'] ?? null) : null,
];
}
public function answerToText(mixed $value, ?string $text, array $settings, ?string $locale = null): string
{
$label = $this->choices($settings, $locale)[$value] ?? (string) $value;
return $text !== null && $text !== '' ? "$label: $text" : $label;
}
public function aggregate(Collection $answers, array $settings): array
{
$answered = $this->answered($answers);
$counts = array_fill_keys(array_keys($this->choices($settings)), 0);
foreach ($answered as $answer) {
if (isset($counts[$answer->value])) {
$counts[$answer->value]++;
}
}
return [
'total' => $answered->count(),
'counts' => $counts,
'conditions' => $answered->where('value', self::MAYBE_IF)->pluck('text')->filter()->values()->all(),
];
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace App\Polls\Types;
/**
* Plain Yes / No, without "Maybe, if...". Reuses the yes/no/maybe-if input and results partials.
*/
class YesNoType extends YesNoConditionalType
{
public function key(): string
{
return 'yes_no';
}
public function label(): string
{
return __('Yes / No');
}
public function defaultSettings(): array
{
return [];
}
public function settingsRules(string $prefix, array $settings): array
{
return [];
}
public function choices(array $settings, ?string $locale = null): array
{
return [self::YES => __('Yes'), self::NO => __('No')];
}
public function inputView(): string
{
return 'polls.types.yes_no_conditional.input';
}
public function resultsView(): string
{
return 'polls.types.yes_no_conditional.results';
}
}