Commit 94b4c544 authored by Korko's avatar Korko
Browse files

Allow several exclusions instead of a single partner

parent d09418c7
......@@ -34,15 +34,15 @@ class RandomFormController extends Controller
$names = $request->input('name');
$emails = $request->input('email');
$phones = $request->input('phone');
$partners = $request->input('partner', []);
$exclusions = $request->input('exclusions', []);
$participants = [];
for ($i = 0; $i < count($names); $i++) {
$participants[$i] = [
'name' => $names[$i],
'email' => $emails[$i],
'phone' => $phones[$i],
'partner' => (!empty($partners[$i]) && $partners[$i] !== '-1') ? $names[$partners[$i]] : null,
'name' => $names[$i],
'email' => $emails[$i],
'phone' => $phones[$i],
'exclusions' => (isset($exclusions[$i])) ? array_map('intval', $exclusions[$i]) : [],
];
}
......
......@@ -26,7 +26,7 @@ class RandomFormRequest extends Request
'name' => 'required|array|min:2|arrayunique',
'email' => 'array',
'phone' => 'array',
'partner' => 'array',
'exclusions' => 'array',
];
if (!empty($this->request->get('name'))) {
......@@ -44,10 +44,18 @@ class RandomFormRequest extends Request
foreach ($this->request->get('name') as $key => $name) {
$rules += [
'email.'.$key => 'required_without:phone.'.$key.'|email',
'phone.'.$key => 'required_without:email.'.$key.'|numeric|regex:#0[67]\d{8}#',
'partner.'.$key => (isset($this->request->get('partner')[$key]) && $this->request->get('partner')[$key] != '-1') ? 'sometimes|integer|fieldinkeys:name,'.$key : 'sometimes|numeric',
'email.'.$key => 'required_without:phone.'.$key.'|email',
'phone.'.$key => 'required_without:email.'.$key.'|numeric|regex:#0[67]\d{8}#',
'exclusions.'.$key => 'sometimes|array',
];
$exclusions = $this->request->get('exclusions') ?: array();
$exclusions = isset($exclusions[$key]) ? $exclusions[$key] : array();
foreach ($exclusions as $key2 => $name2) {
$rules += [
'exclusions.'.$key.'.'.$key2 => 'integer|fieldinkeys:name,'.$key,
];
}
}
}
......
<?php
namespace Korko\SecretSanta\Libs;
use Closure;
use Exception;
use Traversable;
class Combinator
{
public static function all(array $elements, Closure $validator = null) : array
{
$combinations = [];
foreach(self::getGenerator($elements) as $combination) {
if(isset($validator) && !self::isCombinationValid($combination, $validator)) {
continue;
}
$combinations[] = $combination;
}
return $combinations;
}
public static function getGenerator(array $elements) : Traversable
{
if($elements === array()) {
yield;
}
$rndElements = $elements;
shuffle($rndElements);
foreach(self::getGenerator_internal($rndElements) as $combination) {
yield array_combine($elements, $combination);
}
}
private static function getGenerator_internal(array $elements, $combination = array()) : Traversable
{
if($elements === array()) {
yield $combination;
} else {
foreach($elements as $idx => $element) {
// Take the first element
// Add it to the combination
$tmpCombination = $combination;
$tmpCombination[] = $element;
// And remove it from possibilities
$tmpElements = $elements;
unset($tmpElements[$idx]);
yield from self::getGenerator_internal($tmpElements, $tmpCombination);
}
}
}
private static function isCombinationValid(array $combination, Closure $validator) : bool
{
foreach($combination as $elementA => $elementB) {
if(!$validator($elementA, $elementB)) {
return false;
}
}
return true;
}
}
......@@ -6,38 +6,22 @@ use Exception;
class Randomizer
{
public static function randomize(array $santas, array $targets = null)
public static function randomize(array $participants) : array
{
if ($targets === null) {
$targets = array_column($santas, 'name');
}
for ($i = 0; $i < 1000; $i++) {
$hat = $targets;
$matches = [];
foreach ($santas as $key => $santa) {
$target = self::getRandomTargetName($santa, $hat);
if ($target === null) {
continue 2; // Nobody found, reroll
}
$matches[$key] = $target;
$hat = array_diff($hat, [$target]);
}
$combinations = Combinator::all(array_keys($participants), function($santa, $target) use($participants) {
return $santa !== $target && !in_array($target, $participants[$santa]['exclusions']);
});
return $matches;
if($combinations === array()) {
throw new Exception('Cannot resolve '.json_encode($participants));
}
throw new Exception('Cannot resolve '.json_encode($santas).' with '.json_encode($targets));
}
$rnd = array_rand($combinations);
$combination = $combinations[$rnd];
private static function getRandomTargetName($santa, array $hat)
{
$santasHat = array_diff($hat, [$santa['name'], $santa['partner']]);
if ($santasHat === []) {
return;
foreach($combination as $idx => $idx2) {
$combination[$idx] = $participants[$idx2]['name'];
}
return $santasHat[array_rand($santasHat)];
return $combination;
}
}
......@@ -22,6 +22,7 @@ elixir(function(mix) {
'./node_modules/bootstrap-sass/assets/stylesheets/',
'./node_modules/font-awesome/scss/',
'./node_modules/alertify.js/src/sass/',
'./node_modules/multiple-select/',
'./resources/assets/vendor/bootstrap-languages/'
]
});
......@@ -29,4 +30,5 @@ elixir(function(mix) {
mix.copy('resources/assets/vendor/bootstrap-languages/languages.png', 'public/assets/img');
mix.copy('node_modules/bootstrap-sass/assets/fonts', 'public/assets/fonts');
mix.copy('node_modules/font-awesome/fonts', 'public/assets/fonts');
mix.copy('node_modules/multiple-select/multiple-select.png', 'public/assets');
});
......@@ -12962,6 +12962,180 @@ h6 > .lang-xs:after {
top: -6px;
}
/**
* @author zhixin wen <wenzhixin2010@gmail.com>
*/
.ms-parent {
display: inline-block;
position: relative;
vertical-align: middle;
}
.ms-choice {
display: block;
width: 100%;
height: 26px;
padding: 0;
overflow: hidden;
cursor: pointer;
border: 1px solid #aaa;
text-align: left;
white-space: nowrap;
line-height: 26px;
color: #444;
text-decoration: none;
border-radius: 4px;
background-color: #fff;
}
.ms-choice.disabled {
background-color: #f4f4f4;
background-image: none;
border: 1px solid #ddd;
cursor: default;
}
.ms-choice > span {
position: absolute;
top: 0;
left: 0;
right: 20px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
padding-left: 8px;
}
.ms-choice > span.placeholder {
color: #999;
}
.ms-choice > div {
position: absolute;
top: 0;
right: 0;
width: 20px;
height: 25px;
background: url("multiple-select.png") left top no-repeat;
}
.ms-choice > div.open {
background: url("multiple-select.png") right top no-repeat;
}
.ms-drop {
width: 100%;
overflow: hidden;
display: none;
margin-top: -1px;
padding: 0;
position: absolute;
z-index: 1000;
background: #fff;
color: #000;
border: 1px solid #aaa;
border-radius: 4px;
}
.ms-drop.bottom {
top: 100%;
box-shadow: 0 4px 5px rgba(0, 0, 0, 0.15);
}
.ms-drop.top {
bottom: 100%;
box-shadow: 0 -4px 5px rgba(0, 0, 0, 0.15);
}
.ms-search {
display: inline-block;
margin: 0;
min-height: 26px;
padding: 4px;
position: relative;
white-space: nowrap;
width: 100%;
z-index: 10000;
}
.ms-search input {
width: 100%;
height: auto !important;
min-height: 24px;
padding: 0 20px 0 5px;
margin: 0;
outline: 0;
font-family: sans-serif;
font-size: 1em;
border: 1px solid #aaa;
border-radius: 0;
box-shadow: none;
background: #fff url("multiple-select.png") no-repeat 100% -22px;
background: url("multiple-select.png") no-repeat 100% -22px, linear-gradient(top, #ffffff 85%, #eeeeee 99%);
}
.ms-search,
.ms-search input {
-khtml-box-sizing: border-box;
-ms-box-sizing: border-box;
box-sizing: border-box;
}
.ms-drop ul {
overflow: auto;
margin: 0;
padding: 5px 8px;
}
.ms-drop ul > li {
list-style: none;
display: list-item;
background-image: none;
position: static;
}
.ms-drop ul > li .disabled {
opacity: .35;
filter: Alpha(Opacity=35);
}
.ms-drop ul > li.multiple {
display: block;
float: left;
}
.ms-drop ul > li.group {
clear: both;
}
.ms-drop ul > li.multiple label {
width: 100%;
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ms-drop ul > li label {
font-weight: normal;
display: block;
white-space: nowrap;
}
.ms-drop ul > li label.optgroup {
font-weight: bold;
}
.ms-drop input[type="checkbox"] {
vertical-align: middle;
}
.ms-drop .ms-no-results {
display: none;
}
/*
Theme Name: MeatKing
Version: 1.14.20
......
This diff is collapsed.
This diff is collapsed.
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
......@@ -27,8 +27,12 @@ window.app = new Vue({
name: '',
email: '',
phone: '',
exclusions: []
};
},
components: {
select2: require('../vuejs/select2.vue')
},
computed: {
participantNames: function() {
var names = [];
......
......@@ -10,6 +10,8 @@ $fa-font-path: "/assets/fonts";
@import "languages";
@import "multiple-select";
/*
Theme Name: MeatKing
Version: 1.14.20
......
<template>
<select multiple="multiple">
<option v-for="option in options" :value="option.value" :key="option.id || option.value">{{ option.text }}</option>
</select>
</template>
<script>
var jQuery = require('jquery');
require('multiple-select');
export default {
props: ['options', 'value', 'placeholder'],
mounted: function() {
jQuery(this.$el).multipleSelect({selectAll: false, filter: true, placeholder: this.placeholder, ellipsis: true});
},
updated: function() {
jQuery(this.$el).multipleSelect('refresh');
}
}
</script>
<style>
select {
min-width: 300px;
}
</style>
......@@ -38,16 +38,16 @@ Le mot clef \"{TARGET}\" est obligatoire dans le corps du message afin de donner
'participants' => 'Détails des participants',
'participant.name' => 'Nom ou pseudonyme',
'participant.email' => 'Adresse e-mail',
'participant.phone' => 'Téléphone',
'participant.partner' => 'Partenaire',
'participant.name' => 'Nom ou pseudonyme',
'participant.email' => 'Adresse e-mail',
'participant.phone' => 'Téléphone',
'participant.exclusions' => 'Exclusions',
'name.placeholder' => 'exemple : Paul ou Korko',
'email.placeholder' => 'exemple : michel@aol.com',
'phone.placeholder' => 'ex : 0612345678',
'name.placeholder' => 'exemple : Paul ou Korko',
'email.placeholder' => 'exemple : michel@aol.com',
'phone.placeholder' => 'ex : 0612345678',
'exclusions.placeholder' => 'Aucune exclusion',
'partner.none' => 'Aucun',
'participant.remove' => 'Enlever',
'participant.add' => 'Ajouter un participant',
......
......@@ -201,7 +201,7 @@
<th class="col-xs-3">@lang('form.participant.email')</th>
<th class="col-xs-0"></th>
<th class="col-xs-2 col-lg-2">@lang('form.participant.phone')</th>
<th class="col-xs-2">@lang('form.participant.partner')</th>
<th class="col-xs-2">@lang('form.participant.exclusions')</th>
<th class="col-xs-1 col-lg-1"></th>
</tr>
</thead>
......@@ -227,10 +227,7 @@
<input type='tel' pattern='0[67]\d{8}' maxlength="10" name="phone[]" placeholder="@lang('form.phone.placeholder')" v-model="phone" class="form-control participant-phone" :required="!email" />
</td>
<td class="row border-right text-left">
<select name="partner[]" class="form-control participant-partner">
<option value="-1">@lang('form.partner.none')</option>
<option v-for="(partner, pidx) in participants" v-if="partner.name && idx !== pidx" :key="partner.id" :value="pidx">@{{ partner.name }}</option>
</select>
<select2 :options="participantNames" v-model="exclusions" placeholder="@lang('form.exclusions.placeholder')"></select2>
</td>
<td class="row participant-remove-wrapper">
<button type="button" class="btn btn-danger participant-remove" :disabled="participants.length <= 2" @click="$emit('delete')"><span class="glyphicon glyphicon-minus"></span><span> @lang('form.participant.remove')</span></button>
......
<?php
use Korko\SecretSanta\Libs\Combinator;
class CombinatorTest extends TestCase
{
public function assertCombination($valid, $test)
{
$test = array_map(function($el) {
return implode('', $el);
}, $test);
$this->assertEquals(sort($valid), sort($test));
}
public function testNoFilter()
{
$this->assertCombination(array(
'AB', 'BA'
), Combinator::all(['A', 'B']));
}
public function testNoExclusion()
{
$this->assertCombination(array(
'BA'
), Combinator::all(['A', 'B'], function($a, $b) { return $a !== $b; }));
$this->assertCombination(array(
'BCA', 'CAB'
), Combinator::all(['A', 'B', 'C'], function($a, $b) { return $a !== $b; }));
$this->assertCombination(array(
'BCDA', 'BDAC', 'BADC',
'CDAB', 'CADB', 'CDBA',
'DABC', 'DCBA', 'DCAB'
), Combinator::all(['A', 'B', 'C', 'D'], function($a, $b) { return $a !== $b; }));
}
public function testSimpleExclusion()
{
$exclusions = array('A' => ['C']);
$this->assertCombination(array(
'BCA'
), Combinator::all(['A', 'B', 'C'], function($elementA, $elementB) use($exclusions) {
return $elementA !== $elementB && (!isset($exclusions[$elementA]) || !in_array($elementB, $exclusions[$elementA]));
}));
}
/*
public function testComplexExclusion()
{
$participants = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', L'];
$exclusions = ['A' => ['B', 'C', 'D']];
$this->assertEquals(array(['A' => 'B', 'B' => 'C', 'C' => 'A']), Combinator::all($participants, function($elementA, $elementB) use($exclusions) {
return !isset($exclusions[$elementA]) || !in_array($elementB, $exclusions[$elementA]);
}));
}
*/
public function testInvalidExclusion()
{
$this->assertEquals([], Combinator::all(['A', 'B', 'C'], function($elementA, $elementB) {
return false;
}));
}
public function testImpossibleSolution()
{
$exclusions = array('A' => ['B', 'C']);
$this->assertEquals([], Combinator::all(['A', 'B', 'C'], function($elementA, $elementB) use($exclusions) {
return $elementA !== $elementB && (!isset($exclusions[$elementA]) || !in_array($elementB, $exclusions[$elementA]));
}));
}
}
......@@ -7,41 +7,41 @@ class RandomizerTest extends TestCase
public function testDuoRandomize()
{
$this->assertEquals([0 => 'B', 1 => 'A'], Randomizer::randomize([
0 => ['name' => 'A', 'partner' => null],
1 => ['name' => 'B', 'partner' => null],
0 => ['name' => 'A', 'exclusions' => []],
1 => ['name' => 'B', 'exclusions' => []],
]));
$this->assertEquals([0 => 'A', 1 => 'B'], Randomizer::randomize([
0 => ['name' => 'B', 'partner' => null],
1 => ['name' => 'A', 'partner' => null],
0 => ['name' => 'B', 'exclusions' => []],
1 => ['name' => 'A', 'exclusions' => []],
]));
}
public function testTrioRandomize()
{
$this->assertEquals([0 => 'B', 1 => 'C', 2 => 'A'], Randomizer::randomize([
0 => ['name' => 'A', 'partner' => null],
1 => ['name' => 'B', 'partner' => 'A'],
2 => ['name' => 'C', 'partner' => 'B'],
0 => ['name' => 'A', 'exclusions' => []],
1 => ['name' => 'B', 'exclusions' => ['0']],
2 => ['name' => 'C', 'exclusions' => ['1']],
]));
$this->assertEquals([0 => 'C', 1 => 'A', 2 => 'B'], Randomizer::randomize([
0 => ['name' => 'A', 'partner' => 'B'],
1 => ['name' => 'B', 'partner' => 'C'],
2 => ['name' => 'C', 'partner' => null],
0 => ['name' => 'A', 'exclusions' => ['1']],
1 => ['name' => 'B', 'exclusions' => ['2']],
2 => ['name' => 'C', 'exclusions' => []],
]));
}
/**