Snapshot of main at 9f39a6e1e (2026-11-02)
This commit is contained in:
commit
6ccf1792d7
2580 files changed
+1040202
No files matched your search
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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()];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user