Initial xentral_oss_20.3.c9ffacf

This commit is contained in:
Alex
2021-05-21 08:49:41 +02:00
parent 5406e4a551
commit 34e5ac43d9
18884 changed files with 2109867 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace Xentral\Widgets\Chart;
/**
* Spezielles Dataset für Balkendiagramme
*
* Besonderheiten:
* * Balkenhintergrund und -rahmen haben keine Transparenz
*/
class BarDataset extends Dataset
{
/** @var array $defaultAlphaValues Transparenz-Werte */
protected static $defaultAlphaValues = [
'borderColor' => 1.0,
'backgroundColor' => 1.0,
'hoverBorderColor' => 1.0,
];
}
+277
View File
@@ -0,0 +1,277 @@
<?php
namespace Xentral\Widgets\Chart;
use InvalidArgumentException;
class Chart
{
/** @var array $validTypes */
private static $validTypes = [
'line',
'bar',
'radar',
'pie',
'doughnut',
'polarArea',
//'bubble',
//'scatter',
];
/** @var array $defaultColors In RGB */
private static $defaultColors = [
[162, 197, 90],
[69, 185, 211],
[246, 158, 6],
[14, 131, 148],
];
/** @var array $defaultOptions */
private static $defaultOptions = [
'responsive' => true,
'responsiveAnimationDuration' => 0,
'maintainAspectRatio' => true,
'tooltips' => [
'enabled' => true,
'mode' => 'nearest',
'backgroundColor' => 'rgba(0, 0, 0, 0.5)',
],
'legend' => [
'display' => true,
],
'animation' => [
'duration' => 1000,
'animateRotate' => true, // Pie + Doughnut
],
'scales' => [
'xAxes' => [
[
'display' => true,
],
],
'yAxes' => [
[
'display' => true,
'ticks' => [
'beginAtZero' => true,
],
],
],
],
];
protected $type;
protected $labels;
protected $datasets;
protected $options;
/** @var int $currentColor Zähler für die letzte Default-Farbe die verwendet wurde */
private $currentColor;
/**
* @param string $type Chart-Typ
* @param array $labels
* @param array|Dataset[] $datasets
* @param array $options chart.js Optionen
*/
public function __construct($type = 'line', $labels = [], array $datasets = [], array $options = [])
{
if (!in_array($type, self::$validTypes, true)) {
throw new InvalidArgumentException(sprintf('Chart type "%s" is not valid.', $type));
}
// Ungültige Werte in leeres Array wandeln
if (!is_array($labels)) {
$labels = [];
}
$this->type = $type;
$this->labels = $labels;
$this->datasets = $datasets;
$this->options = array_replace_recursive(self::$defaultOptions, $options);
if ($type === 'line') {
$this->options['tooltips']['mode'] = 'index';
}
if ($type === 'doughnut' || $type === 'pie') {
$this->options['scales']['xAxes'][0]['display'] = false;
$this->options['scales']['yAxes'][0]['display'] = false;
}
}
/**
* @param array $labels
*
* @return void
*/
public function addLabels($labels)
{
// Ungültige Werte in leeres Array wandeln
if (!is_array($labels)) {
$labels = [];
}
foreach ($labels as $label) {
$this->addLabel($label);
}
}
/**
* @param string $label
*
* @return void
*/
public function addLabel($label)
{
$this->labels[] = (string)$label;
}
/**
* @param Dataset $dataset
*
* @return void
*/
public function addDataset(Dataset $dataset)
{
$this->datasets[] = $dataset;
}
/**
* Wie setYAxis(), nur dass das Dataset zusätzlich noch zum Chart hinzugefügt wird
*
* @see setYAxis()
*
* @param Dataset $dataset
* @param string $position
* @param string $type
*
* @return void
*/
public function addDatasetAsYAxis(Dataset $dataset, $position = 'left', $type = 'linear')
{
$this->setYAxis($dataset, $position, $type);
$this->addDataset($dataset);
}
/**
* Übergebenes Dataset für die Anzeige der Y-Achse verwenden
*
* * Mehrere Y-Achsen sind möglich
* * Dataset wird aber nicht zum Chart hinzugefügt; muss über addDataset() passieren
*
* @param Dataset $dataset
* @param string $position
* @param string $type
*
* @return void
*/
public function setYAxis(Dataset $dataset, $position = 'left', $type = 'linear')
{
// Falls vorher noch kein Dataset als y-Achse definiert wurde, Default-y-Achsen-Config löschen.
// (Ansonsten wird eine y-Achse zu viel angezeigt)
$hasMultipleAxesConfig = array_key_exists('id', $this->options['scales']['yAxes'][0]);
if (!$hasMultipleAxesConfig) {
unset($this->options['scales']['yAxes']);
}
$id = $dataset->generateYAxisId();
$this->options['scales']['yAxes'][] = [
'id' => $id,
'display' => true,
'type' => $type,
'position' => $position,
'ticks' => [
'beginAtZero' => true,
],
];
}
/**
* @return void
*/
public function accumulateData()
{
foreach ($this->datasets as $dataset) {
$dataset->accumulateData();
}
}
/**
* @return string
*/
public function getType()
{
return $this->type;
}
/**
* @return array
*/
public function getData()
{
foreach ($this->datasets as $dataset) {
/** @var Dataset $dataset */
// Default-Farben durchiterieren, wenn noch keine Farbe gesetzt
if (!$dataset->hasColorAssigned()) {
$color = $this->getNextDefaultColor();
$dataset->setColorByRgb(...$color);
}
}
return [
'labels' => $this->labels,
'datasets' => $this->datasets,
];
}
/**
* @return array
*/
public function getOptions()
{
return $this->options;
}
/**
* @return string
*/
public function toJson()
{
return json_encode([
'type' => $this->getType(),
'data' => $this->getData(),
'options' => $this->getOptions(),
]);
}
/**
* Liefert bei jedem Aufruf die nächste Farbe aus den Standard-Farben
*
* @return array Array mit drei Farbwerten [R, G, B]
*/
private function getNextDefaultColor()
{
// Bei jedem Aufruf Index hochzählen
$this->currentColor = $this->currentColor === null ? 0 : $this->currentColor + 1;
// Wieder vorne anfangen wenn letzte Farbe ausgeliefert wurde
if ($this->currentColor >= count(self::$defaultColors)) {
$this->currentColor = 0;
}
return self::$defaultColors[$this->currentColor];
}
/**
* Beim Klonen die Dataset-Objekte einzeln klonen
*
* Ansonsten beinhaltet das geklonte Chart-Objekt die Referenzen zum Ursprungsobjekt!
*
* @return void
*/
public function __clone()
{
foreach ($this->datasets as $key => $dataset) {
$this->datasets[$key] = clone $dataset;
}
}
}
+225
View File
@@ -0,0 +1,225 @@
<?php
namespace Xentral\Widgets\Chart;
use InvalidArgumentException;
use JsonSerializable;
class Color implements JsonSerializable
{
private $red;
private $green;
private $blue;
private $alpha;
/**
* @param int $red Farbwert von 0 bis 255
* @param int $green Farbwert von 0 bis 255
* @param int $blue Farbwert von 0 bis 255
* @param float $alpha Transparenzwert von 0 bis 1
*/
public function __construct($red = 0, $green = 0, $blue = 0, $alpha = 0.1)
{
$this->red = $this->ensureColorValue($red);
$this->green = $this->ensureColorValue($green);
$this->blue = $this->ensureColorValue($blue);
$this->setAlpha($alpha);
}
/**
* @param string $hexColor Beispiel: "#112233"
*
* @return self
*/
public static function createFromHex($hexColor)
{
$hexColor = str_replace('#', '', $hexColor);
if (strlen($hexColor) !== 6) {
throw new InvalidArgumentException('Only full length hex values are supported.');
}
$parts = str_split($hexColor, 2);
$red = hexdec($parts[0]);
$green = hexdec($parts[1]);
$blue = hexdec($parts[2]);
return new self($red, $green, $blue, 1);
}
/**
* @param string $cssRgba Beispiel: rgba(255, 128, 0, 0.5)
*
* @return self
*/
public static function createFromCssRgba($cssRgba)
{
$cssRgba = str_replace(' ', '', $cssRgba);
preg_match('/^rgba\((\d+),(\d+),(\d+),([\d\.]+)\);?/i', $cssRgba, $colors);
return new self((int)$colors[1], (int)$colors[2], (int)$colors[3], (float)$colors[4]);
}
/**
* @param string $cssRgb Beispiel: "rgb(255, 128, 0)"
*
* @return self
*/
public static function createFromCssRgb($cssRgb)
{
$cssRgb = str_replace(' ', '', $cssRgb);
preg_match('/^rgb\((\d+),(\d+),(\d+)\);?/i', $cssRgb, $colors);
return new self((int)$colors[1], (int)$colors[2], (int)$colors[3], 1.0);
}
/**
* @param int $value
*
* @return int
*/
private function ensureColorValue($value)
{
$value = (int)$value;
if ($value < 0) {
$value = 0;
}
if ($value > 255) {
$value = 255;
}
return $value;
}
/**
* @param float $alpha Wert zwischen 0 und 1
*
* @return void
*/
public function setAlpha($alpha)
{
$alpha = (float)$alpha;
if ($alpha < 0.0) {
$alpha = 0.0;
}
if ($alpha > 1.0) {
$alpha = 1.0;
}
$this->alpha = $alpha;
}
/**
* Farbwerte per Zufall variieren
*
* @param int $difference
*
* @return void
*/
public function makeVariant($difference)
{
$difference = (int)$difference;
$redVariant = $this->red + mt_rand($difference * -1, $difference);
$this->red = $this->ensureColorValue($redVariant);
$greenVariant = $this->green + mt_rand($difference * -1, $difference);
$this->green = $this->ensureColorValue($greenVariant);
$blueVariant = $this->blue + mt_rand($difference * -1, $difference);
$this->blue = $this->ensureColorValue($blueVariant);
}
/**
* Farbe heller machen; verändert nicht die Transparenz
*
* @param float $percent
*
* @return void
*/
public function makeLighter($percent = 10.0)
{
$percent = (float)$percent;
$difference = (int)($percent * 2.55 / 2);
$this->red = $this->ensureColorValue($this->red + $difference);
$this->blue = $this->ensureColorValue($this->blue + $difference);
$this->green = $this->ensureColorValue($this->green + $difference);
}
/**
* Farbe dunkler machen; verändert nicht die Transparenz
*
* @param float $percent
*
* @return void
*/
public function makeDarker($percent = 10.0)
{
$percent = (float)$percent;
$difference = (int)($percent * 2.55 / 2);
$this->red = $this->ensureColorValue($this->red - $difference);
$this->blue = $this->ensureColorValue($this->blue - $difference);
$this->green = $this->ensureColorValue($this->green - $difference);
}
/**
* Ausgabe in CSS rgba() Notation
*
* @return string
*/
public function toCssRgba()
{
return sprintf(
'rgba(%s, %s, %s, %s)',
$this->red,
$this->green,
$this->blue,
number_format($this->alpha, 3, '.', '')
);
}
/**
* Ausgabe in CSS rgb() Notation; Transparenz geht verloren
*
* @return string
*/
public function toCssRgb()
{
return sprintf(
'rgb(%s, %s, %s)',
$this->red,
$this->green,
$this->blue
);
}
/**
* Ausgabe als Hex-Farbwert; Transparenz geht verloren
*
* @return string
*/
public function toHex()
{
return sprintf(
'#%s%s%s',
str_pad(dechex($this->red), 2, '0', STR_PAD_LEFT),
str_pad(dechex($this->green), 2, '0', STR_PAD_LEFT),
str_pad(dechex($this->blue), 2, '0', STR_PAD_LEFT)
);
}
/**
* @return string
*/
public function jsonSerialize()
{
return $this->__toString();
}
/**
* @return string
*/
public function __toString()
{
return $this->toCssRgba();
}
}
+281
View File
@@ -0,0 +1,281 @@
<?php
namespace Xentral\Widgets\Chart;
use InvalidArgumentException;
use JsonSerializable;
class Dataset implements JsonSerializable
{
const LINE_STYLE_SOLID = 'solid';
const LINE_STYLE_DASHED = 'dashed';
const LINE_STYLE_DOTTED = 'dotted';
const COLOR_GREEN = 'green';
const COLOR_BLUE = 'blue';
const COLOR_ORANGE = 'orange';
const COLOR_DARKBLUE = 'darkblue';
/** @var array $validLineStyles */
protected static $validLineStyles = [
self::LINE_STYLE_SOLID,
self::LINE_STYLE_DOTTED,
self::LINE_STYLE_DASHED,
];
/** @var array $colorMap */
protected static $colorMap = [
self::COLOR_GREEN => [162, 197, 90],
self::COLOR_BLUE => [69, 185, 211],
self::COLOR_ORANGE => [246, 158, 6],
self::COLOR_DARKBLUE => [14, 131, 148],
];
/** @var array $defaultOptions Standardwerte für Dataset-Optionen */
protected static $defaultOptions = [
'pointBorderWidth' => 1,
'pointHoverBorderWidth' => 2,
'pointRadius' => 1,
'pointHoverRadius' => 5,
'pointHitRadius' => 15,
'lineTension' => 0.2,
'fill' => true,
'borderWidth' => 3,
'borderDash' => [],
'borderCapStyle' => 'butt',
'borderColor' => 'rgba(0, 0, 0, 0.1)',
'backgroundColor' => 'rgba(0, 0, 0, 0.1)',
'pointBackgroundColor' => 'rgba(0, 0, 0, 0.1)',
];
/** @var array $defaultAlphaValues Transparenz-Werte */
protected static $defaultAlphaValues = [
'borderColor' => 1.0,
'backgroundColor' => 0.1,
'pointBackgroundColor' => 1.0,
];
protected $data;
protected $label;
protected $options;
/** @var bool $isDataAccumulated Gibt an ob Daten bereits kumuliert wurden */
protected $isDataAccumulated = false;
/** @var bool $hasColorSet Gibt an ob bereits eine Farbe manuell gesetzt wurde */
protected $hasColorAssigned = false;
/**
* @param string $label
* @param array $data
* @param array $options
*/
public function __construct($label, $data, array $options = [])
{
// Ungültige Werte in leeres Array wandeln
if (!is_array($data)) {
$data = [];
}
$this->data = $data;
$this->label = $label;
$this->options = array_replace(self::$defaultOptions, $options);
$defaultColor = new Color(0, 0, 0, 0.1);
if (!is_object($this->options['borderColor'])) {
$this->options['borderColor'] = $defaultColor;
}
if (!is_object($this->options['backgroundColor'])) {
$this->options['backgroundColor'] = $defaultColor;
}
if (!is_object($this->options['pointBackgroundColor'])) {
$this->options['pointBackgroundColor'] = $defaultColor;
}
}
/**
* @return int
*/
public function getDataCount()
{
return count($this->data);
}
/**
* @return bool Wurde bereits eine Farbe gesetzt
*/
public function hasColorAssigned()
{
return $this->hasColorAssigned;
}
/**
* @return void
*/
public function accumulateData()
{
if ($this->isDataAccumulated === true) {
return;
}
$sum = 0;
foreach ($this->data as $key => $value) {
$sum += (float)$value;
$this->data[$key] = $sum;
}
$this->isDataAccumulated = true;
}
/**
* @param string $lineStyle
*
* @return void
*/
public function setLineStyle($lineStyle)
{
if (!in_array($lineStyle, self::$validLineStyles, true)) {
throw new InvalidArgumentException(sprintf(
'Line style "%s" is not valid.', $lineStyle
));
}
if ($lineStyle === self::LINE_STYLE_DOTTED) {
$this->options['borderDash'] = [1, 15];
$this->options['borderCapStyle'] = 'round';
}
if ($lineStyle === self::LINE_STYLE_DASHED) {
$this->options['borderDash'] = [15, 10];
$this->options['borderCapStyle'] = 'butt';
}
if ($lineStyle === self::LINE_STYLE_SOLID) {
$this->options['borderDash'] = [];
$this->options['borderCapStyle'] = 'butt';
}
}
/**
* @param Color $color
*
* @return void
*/
public function setColor(Color $color)
{
$borderColor = clone $color;
$backgroundColor = clone $color;
$pointBackgroundColor = clone $color;
$borderColor->setAlpha($this->getDefaultAlphaValue('borderColor'));
$backgroundColor->setAlpha($this->getDefaultAlphaValue('backgroundColor'));
$pointBackgroundColor->setAlpha($this->getDefaultAlphaValue('pointBackgroundColor'));
$this->options['borderColor'] = $borderColor;
$this->options['backgroundColor'] = $backgroundColor;
$this->options['pointBackgroundColor'] = $pointBackgroundColor;
$this->hasColorAssigned = true;
}
/**
* @param int $red
* @param int $green
* @param int $blue
*
* @return void
*/
public function setColorByRgb($red, $green, $blue)
{
$this->setColor(new Color($red, $green, $blue, 1.0));
}
/**
* @param string $hexColor
*
* @return void
*/
public function setColorByHex($hexColor)
{
$hexColor = str_replace('#', '', $hexColor);
if (strlen($hexColor) !== 6) {
throw new InvalidArgumentException('Only full length hex values are supported.');
}
$parts = str_split($hexColor, 2);
$red = hexdec($parts[0]);
$green = hexdec($parts[1]);
$blue = hexdec($parts[2]);
$this->setColorByRgb($red, $green, $blue);
}
/**
* @param string $colorName
*
* @return void
*/
public function setColorByName($colorName)
{
if (!isset(self::$colorMap[$colorName])) {
throw new InvalidArgumentException(sprintf(
'Color name "%s" is not valid.', $colorName
));
}
$rgb = self::$colorMap[$colorName];
$this->setColorByRgb(...$rgb);
}
/**
* Zufällige ID generieren; wird für Multiple Axes Config benötigt
*
* @see http://www.chartjs.org/docs/latest/axes/cartesian/#axis-id
*
* @return string
*/
public function generateYAxisId()
{
if (!isset($this->options['yAxisID'])) {
$this->options['yAxisID'] = uniqid('', false);
}
return $this->options['yAxisID'];
}
/**
* @return array
*/
public function jsonSerialize()
{
return $this->toArray();
}
/**
* @return array
*/
public function toArray()
{
$result = $this->options;
$result['data'] = $this->data;
if (!empty($this->label)) {
$result['label'] = $this->label;
}
return $result;
}
/**
* @param string $colorType
*
* @return float
*/
protected function getDefaultAlphaValue($colorType)
{
if (!isset(static::$defaultAlphaValues[$colorType])) {
return 1.0;
}
// Wichtig: static nicht self!
// Sonst hat Überschreiben von $defaultAlphaValues in abgeleiteten Klassen keine Auswirkung.
return static::$defaultAlphaValues[$colorType];
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
namespace Xentral\Widgets\Chart;
class HtmlRenderer
{
/** @var Chart $chart */
protected $chart;
/** @var string $title */
protected $title;
/** @var int $width */
protected $width;
/** @var int $height */
protected $height;
/** @var array $attributes */
protected $attributes;
/**
* @param Chart $chart
* @param string $title
* @param int $width
* @param int $height
* @param array $attributes Zusätzliche HTML-Attribute als assoziatives Array
*/
public function __construct(Chart $chart, $title = '', $width = 400, $height = 200, array $attributes = [])
{
$this->chart = $chart;
$this->title = $title;
$this->width = (int)$width;
$this->height = (int)$height;
$this->attributes = $attributes;
}
/**
* @return string
*/
public function render()
{
return sprintf(
'<div class="chart-wrapper" %s>%s' .
' <div class="chart-content">' .
' <canvas data-graph-id="%s" width="%s" height="%s"></canvas>' .
' <script type="application/json">%s</script>' .
' </div>' .
'</div>',
$this->renderAttributes(),
$this->renderTitle(),
uniqid(null, false),
$this->width,
$this->height,
$this->chart->toJson()
);
}
/**
* @return string
*/
protected function renderAttributes()
{
$result = '';
foreach ($this->attributes as $key => $value) {
$result .= sprintf(' %s="%s"', $key, $value);
}
return trim($result);
}
/**
* @return string
*/
protected function renderTitle()
{
if (empty($this->title)) {
return '';
}
return sprintf('<div class="chart-title">%s</div>', $this->title);
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace Xentral\Widgets\Chart;
use DateInterval;
use DatePeriod;
use DateTimeInterface;
class PeriodMatcher
{
/** @var DateTimeInterface $start */
protected $start;
/** @var DateTimeInterface $end */
protected $end;
/** @var DateInterval $interval */
protected $interval;
/** @var string $format */
protected $format;
/** @var DatePeriod $range */
protected $range;
/**
* @param DateTimeInterface $start
* @param DateTimeInterface $end
* @param DateInterval $interval
* @param string $format Formate der PHP date()-Funktion
*/
public function __construct(
DateTimeInterface $start,
DateTimeInterface $end,
DateInterval $interval,
$format = 'Y.m.d'
) {
$this->start = $start;
$this->end = $end;
$this->interval = $interval;
$this->format = $format;
$this->range = new DatePeriod($start, $interval, $end);
}
/**
* @param array $data
* @param string $dateKey
* @param string $valueKey
*
* @return array
*/
public function matchData($data, $dateKey, $valueKey)
{
if ($data === null) {
$data = [];
}
$dates = array_column($data, $dateKey);
$values = array_column($data, $valueKey);
$result = [];
foreach ($this->getDates() as $date) {
$matchedKey = array_search($date, $dates, true);
$result[] = $matchedKey !== false ? (float)$values[$matchedKey] : 0.0;
}
return $result;
}
/**
* @param string $format Datumsformat überschreiben
*
* @return array
*/
public function getDates($format = null)
{
$result = [];
/** @var DateTimeInterface $date */
foreach ($this->range as $date) {
$result[] = $date->format($format !== null ? $format : $this->format);
}
return $result;
}
}
+125
View File
@@ -0,0 +1,125 @@
<?php
namespace Xentral\Widgets\Chart;
/**
* Spezielles Dataset für Pie-/Doughnut-Charts
*
* Besonderheiten:
* * Farben müssen als Array angegeben werden; pro Wert eine Farbe
* * Strichbreiten ('borderWidth') müssen als Array angegeben werden; pro Wert eine Breite
*
* http://www.chartjs.org/docs/master/charts/doughnut.html#dataset-properties
*/
class PieDataset extends Dataset
{
/** @var array $defaultAlphaValues Transparenz-Werte */
protected static $defaultAlphaValues = [
'borderColor' => 1.0,
'backgroundColor' => 1.0,
'hoverBorderColor' => 1.0,
];
/** @var int $colorPointer Merkt sich die zuletzt verwendete Default-Color */
private $colorPointer = 0;
/**
* @inheritdoc
*/
public function __construct($label, $data, array $options = [])
{
parent::__construct($label, $data, $options);
$this->options['borderWidth'] = 3;
unset($this->options['pointBackgroundColor']);
}
/**
* @param Color $color
*
* @return void
*/
public function setColor(Color $color)
{
$backgroundColor = clone $color;
$borderColor = new Color(255, 255, 255);
$hoverBorderColor = new Color(255, 255, 255);
$borderColor->setAlpha($this->getDefaultAlphaValue('borderColor'));
$backgroundColor->setAlpha($this->getDefaultAlphaValue('backgroundColor'));
$hoverBorderColor->setAlpha($this->getDefaultAlphaValue('hoverBorderColors'));
// Hintergrundfarbe als Array setzen
// Pro Datensatz Farbe etwas heller machen
$backgroundColors = [];
$dataCount = $this->getDataCount();
foreach ($this->data as $key => $value) {
$difference = $key * 75 / $dataCount;
$backgroundColorLighter = clone $backgroundColor;
$backgroundColorLighter->makeLighter($difference);
$backgroundColors[$key] = $backgroundColorLighter;
}
$this->options['borderColor'] = $borderColor;
$this->options['backgroundColor'] = $backgroundColors;
$this->options['hoverBorderColor'] = $hoverBorderColor;
$this->hasColorAssigned = true;
}
/**
* Hintergrundfarben pro Wert hinterlegen
*
* Die Anzahl der Farben sollte der Anzahl der Daten entsprechen
*
* @param array|string[] $colors Hexadecimal- oder RGB-Schreibweise
*
* @return void
*/
public function setColors(array $colors)
{
// Rahmenfarbe auf Weiß setzen
$borderColor = new Color(255, 255, 255);
$hoverBorderColor = new Color(255, 255, 255);
$borderColor->setAlpha($this->getDefaultAlphaValue('borderColor'));
$hoverBorderColor->setAlpha($this->getDefaultAlphaValue('hoverBorderColors'));
// Nur Hintergrundfarbe/Füllfarbe variieren
$backgroundColors = [];
//foreach ($colors as $color) {
foreach ($this->data as $key => $value) {
$color = $colors[$key];
if (strpos($color, '#', 0) === 0) {
$backgroundColor = Color::createFromHex($color);
} else {
$rgb = $this->getNextDefaultColor();
$backgroundColor = new Color($rgb[0], $rgb[1], $rgb[2], 1);
}
$backgroundColor->setAlpha($this->getDefaultAlphaValue('backgroundColor'));
$backgroundColors[] = $backgroundColor;
}
$this->options['borderColor'] = $borderColor;
$this->options['hoverBorderColor'] = $hoverBorderColor;
$this->options['backgroundColor'] = $backgroundColors;
$this->hasColorAssigned = true;
}
/**
* @return array RGB-Array
*/
private function getNextDefaultColor()
{
$this->colorPointer++;
if ($this->colorPointer >= count(self::$colorMap)) {
$this->colorPointer = 0;
}
$colors = array_values(self::$colorMap);
return $colors[$this->colorPointer];
}
}
@@ -0,0 +1,48 @@
<?php
namespace Xentral\Widgets\ChunkedUpload;
final class Bootstrap
{
/**
* @return array
*/
public static function registerJavascript()
{
return [
'chunkedupload' => [
'./classes/Widgets/ChunkedUpload/www/js/jquery.chunkedUpload.js',
],
];
}
/**
* @return array
*/
public static function registerStylesheets()
{
return [
'chunkedupload' => [
'./classes/Widgets/ChunkedUpload/www/css/chunked_upload.css',
],
];
}
/**
* @return array
*/
public static function registerServices()
{
return [
'ChunkedUploadRequestHandler' => 'onInitChunkedUploadRequestHandler',
];
}
/**
* @return ChunkedUploadRequestHandler
*/
public static function onInitChunkedUploadRequestHandler()
{
return new ChunkedUploadRequestHandler();
}
}
@@ -0,0 +1,215 @@
<?php
namespace Xentral\Widgets\ChunkedUpload;
use Xentral\Components\Http\JsonResponse;
use Xentral\Components\Http\Request;
use Xentral\Components\Util\StringUtil;
use Xentral\Widgets\ChunkedUpload\Exception\ChunkedUploadExceptionInterface;
use Xentral\Widgets\ChunkedUpload\Exception\DecodingFailedException;
use Xentral\Widgets\ChunkedUpload\Exception\FilesystemErrorException;
final class ChunkedUploadRequestHandler
{
/**
* @param Request $request
*
* @return bool
*/
public function canHandleRequest(Request $request)
{
if ($request->isCli() || !$request->isAjax()) {
return false;
}
if (!$request->post->has('file_id') ||
!$request->post->has('file_name') ||
!$request->post->has('file_data') ||
!$request->post->has('file_size')) {
return false;
}
return strtolower($request->getHeader('Content-Type')) === 'application/x-www-form-urlencoded; charset=utf-8';
}
/**
* @param Request $request
* @param string $tempDir Absolute path to directory, where the unfinished file will be stored
* @param string $saveDir Absolute path to directory, where the final upload will be stored
*
* @return JsonResponse
*/
public function handleRequest(Request $request, $tempDir, $saveDir, $newFilename = null)
{
try {
$bytesWritten = $this->handleUpload($request, $tempDir, $saveDir, $newFilename);
} catch (ChunkedUploadExceptionInterface $exception) {
return new JsonResponse([
'success' => false,
'error' => $exception->getMessage(),
], JsonResponse::HTTP_NOT_FOUND);
}
// Antwort zusammenbauen
$responseData = [
'success' => true,
'file' => [
'id' => $request->getPost('file_id'),
'bytes' => $bytesWritten,
],
];
// Beim ersten Request das Upload-Limit von PHP in der Antwort mitschicken
// Erklärung:
// Der erste Chunk ist bewusst klein gewählt (100KB), damit auf keinen Fall das Upload-Limit von PHP greift.
// Die Antwort nach dem Upload des ersten Chunks enthält das Upload-Limit von PHP.
// Der Uploader passt die Chunk-Size an, falls diese über dem Upload-Limit von PHP liegt.
$fileOffset = $request->getPost('file_offset', null);
if ($fileOffset !== null && (int)$fileOffset === 0) {
$responseData['uploadLimit'] = $this->determineMaxUploadSize();
}
return new JsonResponse($responseData);
}
/**
* @param Request $request
* @param string $tempDir Absolute path to directory, where the unfinished file will be stored
* @param string $saveDir Absolute path to directory, where the final upload will be stored
* @param string|null $newFileName
*
* @FilesystemErrorException
*
* @return int Bytes written
*/
private function handleUpload(Request $request, $tempDir, $saveDir, $newFileName = null)
{
if (!is_dir($tempDir)) {
throw new FilesystemErrorException(sprintf('Temporary upload directory does not exist: %s', $tempDir));
}
if (!is_dir($saveDir)) {
throw new FilesystemErrorException(sprintf('Final storage directory does not exist: %s', $saveDir));
}
$fileId = $request->getPost('file_id');
$fileName = $request->getPost('file_name');
$fileData = $request->getPost('file_data');
$fileSize = (int)$request->getPost('file_size');
$fileHash = sha1(json_encode(['id' => $fileId, 'name' => $newFileName ?? $fileName, 'size' => $fileSize]));
$tempPath = realpath($tempDir) . '/' . $fileHash;
$savePath = realpath($saveDir) . '/' . ($newFileName ?? $fileName);
$bytesWritten = $this->writeChunkData($tempPath, $fileData, $fileName);
$tempSize = (int)@filesize($tempPath);
if (file_exists($savePath)) {
@unlink($tempPath);
throw new FilesystemErrorException(sprintf(
'Pre check failed. Final file already exists: %s', $savePath
));
}
if ($tempSize > $fileSize) {
@unlink($tempPath);
throw new FilesystemErrorException(sprintf(
'Unknown Error: Temporary file is larger than uploaded file: %s', $tempPath
));
}
if ($tempSize === $fileSize) {
$this->moveFinishedFile($tempPath, $savePath);
}
return $bytesWritten;
}
/**
* @param string $tempPath Absolute path to temp file
* @param string $savePath Absolute path to final file
*
* @throws FilesystemErrorException
*
* @return void
*/
private function moveFinishedFile($tempPath, $savePath)
{
if (!file_exists($tempPath)) {
throw new FilesystemErrorException(sprintf(
'Failed to move temporary file to final location. Temp file is missing: %s', $tempPath
));
}
if (!@rename($tempPath, $savePath)) {
@unlink($tempPath);
throw new FilesystemErrorException(sprintf(
'Failed to move temporary file to final location: %s', $savePath
));
}
@unlink($tempPath);
}
/**
* @param string $tempPath Absolute path to temp file; chunk data will be appended
* @param string $fileData Base64 encoded chunk data
* @param string $fileName File name; without directory; Only needed for debugging
*
* @throws FilesystemErrorException
*
* @return int Bytes written
*/
private function writeChunkData($tempPath, $fileData, $fileName)
{
$resource = @fopen($tempPath, 'a+b');
if ($resource === false) {
throw new FilesystemErrorException(sprintf('Can not open file for writing: %s', $tempPath));
}
$binaryData = $this->decodeChunkData($fileData, $fileName);
$bytesWritten = @fwrite($resource, $binaryData);
if ($bytesWritten === false) {
@unlink($tempPath);
throw new FilesystemErrorException(sprintf('Can not write to file: %s', $tempPath));
}
if (@fclose($resource) === false) {
@unlink($tempPath);
throw new FilesystemErrorException(sprintf('Could not close file pointer: %s', $tempPath));
}
return (int)$bytesWritten;
}
/**
* @param string $data Example 'data:application/octet-stream;base64,S0cJXKqx01pYOVeXbdtv...'
* @param string $fileName File name; without directory; Only needed for debugging
*
* @throws DecodingFailedException
*
* @return string Decoded binary data chunk
*/
private function decodeChunkData($data, $fileName)
{
$parts = explode(';base64,', $data);
if (!is_array($parts) || !isset($parts[1])) {
throw new DecodingFailedException(sprintf('Could not decode file upload #1. File name: %s', $fileName));
}
$binaryData = base64_decode($parts[1]);
if ($binaryData === false) {
throw new DecodingFailedException(sprintf('Could not decode file upload #2. File name: %s', $fileName));
}
return $binaryData;
}
/**
* @return int Max upload size per file in bytes
*/
private function determineMaxUploadSize()
{
$fileLimit = StringUtil::parsePhpByteSize(ini_get('upload_max_filesize'));
$postLimit = StringUtil::parsePhpByteSize(ini_get('post_max_size'));
$memLimit = StringUtil::parsePhpByteSize(ini_get('memory_limit'));
return min($fileLimit, $postLimit, $memLimit);
}
}
@@ -0,0 +1,9 @@
<?php
namespace Xentral\Widgets\ChunkedUpload\Exception;
use Xentral\Core\Exception\WidgetExceptionInterface;
interface ChunkedUploadExceptionInterface extends WidgetExceptionInterface
{
}
@@ -0,0 +1,9 @@
<?php
namespace Xentral\Widgets\ChunkedUpload\Exception;
use RuntimeException;
class DecodingFailedException extends RuntimeException implements ChunkedUploadExceptionInterface
{
}
@@ -0,0 +1,9 @@
<?php
namespace Xentral\Widgets\ChunkedUpload\Exception;
use RuntimeException;
class FilesystemErrorException extends RuntimeException implements ChunkedUploadExceptionInterface
{
}
@@ -0,0 +1,45 @@
# ChunkedUpload-Widget
## Einrichtung
### Im Modul
```php
/** @var \Xentral\Components\Http\Request $request */
$request = $this->app->Container->get('Request');
/** @var \Xentral\Widgets\ChunkedUpload\ChunkedUploadRequestHandler $handler */
$handler = $this->app->Container->get('ChunkedUploadRequestHandler');
if ($handler->canHandleRequest($request)) {
$tempDir = $this->app->erp->GetTMP(); // alternativ sys_get_temp_dir();
$saveDir = __DIR__ . '/uploads';
$response = $handler->handleRequest($request, $tempDir, $saveDir);
$response->send();
$this->app->erp->ExitWawi();
}
```
Zum Laden des benötigten jQuery-Plugins reicht folgende Zeile im Modul:
```php
$this->app->ModuleScriptCache->IncludeWidgetNew('ChunkedUpload');
```
### Im Template
```html
<input type="file" id="chunkyfile" multiple="multiple">
```
### In Javascript
```javascript
$(document).ready(function () {
$('#chunkyfile').chunkedUpload({
upload: {
url: 'index.php?module=meinmodul&action=meineaction&cmd=upload'
}
});
});
```
@@ -0,0 +1,12 @@
.chunked-file-upload-container table {
table-layout: fixed;
}
.chunked-file-upload-container table th,
.chunked-file-upload-container table td {
padding: 3px 6px;
}
.chunked-file-upload-container table .button {
display: inline-block;
}
@@ -0,0 +1,545 @@
/**
* # ChunkedUpload
*
* ## Initialisierung
*
* ```html
* <input type="file" id="chunky">
* <div id="files"></div>
*
* <script type="application/javascript">
* $('#chunky').chunkedUpload({
* chunkSize: 6291456, // 6MB
* upload: {
* url: 'index.php?module=foo&action=bar',
* },
* filesContainer: '#files'
* });
* </script>
* ```
*
* ## Callback
*
* ### `fileComplete`
*
* ```javascript
* $('#chunky').chunkedUpload({
* fileComplete: function(fileInfo) {
* // Do something when file upload is completed
* // fileInfo.id = Unique id; example: 'chunked_upload_2377390993'
* // fileInfo.name = Client file name
* // fileInfo.type = File mime type
* // fileInfo.size = File size in bytes
* }
* });
* ```
*
*/
(function ($) {
'use strict';
var ChunkedUpload = function ($elem, options) {
var STATUS = {
WAITING: 'waiting', // Datei wurde hinzugefügt; Es wird gewartet dass Benutzer den Upload startet
UPLOADING: 'uploading', // Datei wird gerade hochgeladen
FINISHED: 'finished', // Upload verarbeitet
FAILURE: 'failure' // Fehler beim Upload
};
var me = {
/** @property {Object} me.options Default configuration */
options: {
chunkSize: 6291456, // 6291456 = 6MB
upload: {
url: null,
view: 'standard',
formData: {}
},
filesContainer: '#chunked-upload-files',
/**
* Callback wenn Datei erfolgreich hochgeladen wurde
*
* @param {FileInfo} fileInfo
*/
fileComplete: function (fileInfo) {}
},
/** @property {Object} me.storage Runtime storage */
storage: {
$fileInput: null,
$filesContainer: null,
$filesList: null,
uploads: {}
},
/**
* @param {HTMLElement} element
* @param {Object} options
*/
init: function (element, options) {
// Prüfen ob HTML5-File-API verfügbar
if (!me.isFileApiAvailable()) {
alert(
'Die HTML5 File-API wird von ihrem Browser nicht unterstützt. ' +
'Bitte verwenden sie einen moderneren Browser.'
);
return;
}
// Optionen mit Defaults mergen
me.options = $.extend({}, me.options, options);
if (typeof me.options.upload.url === 'undefined' || me.options.upload.url === null) {
throw 'Initialisierung nicht möglich. Upload-URL fehlt.';
}
if (typeof me.options.upload.formData === 'undefined' || me.options.upload.formData === null) {
me.options.upload.formData = {};
}
var $fileInput = $(element);
if ($fileInput.length !== 1) {
throw 'File-Input Element wurde nicht gefunden.';
}
if (!$fileInput.is('input[type=file]')) {
alert('Init-Element muss ein "input"-Element vom Typ "file" sein.');
return;
}
$fileInput.on('change', me.onSelectFilesEventHandler);
me.storage.$fileInput = $fileInput;
me.createFilesContainer();
},
/**
* @param {Event} event
*/
onSelectFilesEventHandler: function (event) {
/** @var {FileList} files */
var files = event.target.files;
$.each(files, function (index, file) {
me.addFileUpload(file);
});
// File-Input leeren
var $input = $(this);
$input.replaceWith($input.val('').clone(true));
},
/**
* @param {File} fileObject
*/
addFileUpload: function (fileObject) {
var fileId = me.generateRandomId();
var fileSize = me.formatBytes(fileObject.size);
var uploadObject = {
id: fileId,
file: fileObject,
reader: new FileReader(),
status: STATUS.WAITING,
elements: {
$progressBar: null,
$statusInfo: null,
$actionCell: null
}
};
var $removeFileButton = $('<a>');
$removeFileButton.attr('href', '#').text('Entfernen');
$removeFileButton.addClass('chuncked-removefile-trigger').addClass('button');
$removeFileButton.on('click', function (event) {
event.preventDefault();
var $link = $(this);
var $row = $link.parents('tr');
var fileId = $row.data('fileId');
me.removeFile(fileId);
});
var $uploadFileButton = $('<a>');
$uploadFileButton.attr('href', '#').text('Hochladen');
$uploadFileButton.addClass('chuncked-startupload-trigger').addClass('button');
$uploadFileButton.on('click', function (event) {
event.preventDefault();
var $link = $(this);
var $row = $link.parents('tr');
var fileId = $row.data('fileId');
me.startUpload(fileId);
});
var $progressBar = $('<progress>').attr('min', '0').attr('max', '100').val(0);
var $row = $('<tr>').attr('id', fileId).data('fileId', fileId);
if(me.options.upload.view === 'sidebar') {
var $td = $('<td>').appendTo($row);
var $table = $('<table>').appendTo($td);
$('<td>').addClass('filename').html(fileObject.name).appendTo($('<tr>').appendTo($table)).before('<td>Dateiname:</td>');
$('<td>').addClass('filesize').html(fileSize).appendTo($('<tr>').appendTo($table)).before('<td>Gr&ouml;&szlig;e:</td>');
$('<td>').addClass('fileprogress').html($progressBar).appendTo($('<tr>').appendTo($table)).before('<td>Fortschritt:</td>');
var $statusInfo = $('<td>').addClass('filestatus').html('Bereit zum Hochladen').appendTo($('<tr>').appendTo($table)).before('<td>Status:</td>');
var $actionsCell = $('<td>').addClass('fileaction').appendTo($('<tr>').data('fileId', fileId).appendTo($table));
}
else {
$('<td>').addClass('filename').html(fileObject.name).appendTo($row);
$('<td>').addClass('filesize').html(fileSize).appendTo($row);
$('<td>').addClass('fileprogress').html($progressBar).appendTo($row);
var $statusInfo = $('<td>').addClass('filestatus').html('Bereit zum Hochladen').appendTo($row);
var $actionsCell = $('<td>').addClass('fileaction').appendTo($row);
}
$actionsCell.append($removeFileButton);
$actionsCell.append($uploadFileButton);
$row.appendTo(me.storage.$filesList);
uploadObject.elements.$progressBar = $progressBar;
uploadObject.elements.$statusInfo = $statusInfo;
uploadObject.elements.$actionCell = $actionsCell;
me.storage.uploads[fileId] = uploadObject;
me.storage.$filesContainer.show();
me.storage.$filesContainer.find('table').show();
},
/**
* @param {String} uploadId
*/
removeFile: function (uploadId) {
var $row = $('#' + uploadId);
if ($row.length === 0) {
alert('Can not remove file upload. Element "#' + uploadId + '" not found.');
return;
}
// Datei existiert nicht (mehr?)
if (!me.storage.uploads.hasOwnProperty(uploadId)) {
return;
}
// Upload läuft gerade
if (me.storage.uploads[uploadId].status === STATUS.UPLOADING) {
return; // @todo Laufenden Upload abbrechen
}
// Tabellenzeile entfernen
$row.remove();
delete me.storage.uploads[uploadId];
},
/**
* @ŧodo Upload starten
*
* @param {String} uploadId
*/
startUpload: function (uploadId) {
if (me.storage.uploads.hasOwnProperty(uploadId)) {
var upload = me.storage.uploads[uploadId];
me.startSingleUpload(upload);
}
},
/**
* @param {Object} upload Einzelner Wert aus me.storage.waiting
*/
startSingleUpload: function (upload) {
if (upload === null) {
return;
}
var fileId = upload.id;
if (typeof fileId === 'undefined') {
throw 'Upload fehlgeschlagen. Unique-ID is missing.';
}
var $tableRow = $('#' + fileId);
var $statusCell = $tableRow.find('.filestatus');
$statusCell.html('Bitte warten...');
upload.elements.$progressBar.val(0);
me.uploadFileChunk(upload, 0);
},
/**
* @param {object} upload
* @param {number} start
*/
uploadFileChunk: function (upload, start) {
var chunkSize = me.options.chunkSize;
// ChunkSize kleiner 10KB macht keinen Sinn; Upload verhindern
if (chunkSize < 10240) {
me.displayUploadError(upload.id, 'ChunkSize ist zu gering (<= 10KB). Upload nicht möglich.');
return;
}
// Im allerersten Upload die ChunkSize auf 100KB stellen
// Server schickt in seiner Antwort das PHP-Upload-Limit mit
if (start === 0) {
chunkSize = 102400; // 102400 = 100KB
}
var offset = start + chunkSize + 1;
var chunkBlob = upload.file.slice(start, offset);
var fileId = upload.id;
if (typeof fileId === 'undefined') {
throw 'Upload fehlgeschlagen. Unique-ID is missing.';
}
upload.status = STATUS.UPLOADING;
upload.elements.$statusInfo.html('Hochladen...');
upload.elements.$actionCell.html('');
// Datei-Inhalt fertig eingelesen => Upload starten
upload.reader.onloadend = function (event) {
if (event.target.readyState !== FileReader.DONE) {
return;
}
var ajaxData = me.options.upload.formData;
ajaxData.file_data = event.target.result;
ajaxData.file_name = upload.file.name;
ajaxData.file_type = upload.file.type;
ajaxData.file_size = upload.file.size;
ajaxData.file_id = upload.id;
ajaxData.file_offset = start;
upload.xhr = $.ajax({
url: me.options.upload.url,
type: 'POST',
dataType: 'json',
cache: false,
data: ajaxData,
error: function (jqXHR, textStatus, errorThrown) {
var errorMessage = 'Unbekannter Fehler #21: ' + errorThrown;
// User hat "Abbrechen" geklickt
if (textStatus === 'abort') {
errorMessage = 'Upload abgebrochen';
}
// PHP-Skript hat Fehler geliefert (z.b. 404)
if (textStatus === 'error') {
errorMessage = 'Unbekannter Server-Fehler';
}
// PHP-Skript liefer JSON-Error-Response
if (jqXHR.hasOwnProperty('responseJSON') && jqXHR.responseJSON.hasOwnProperty('error')) {
errorMessage = 'Server-Fehler: ' + jqXHR.responseJSON.error;
}
upload.elements.$statusInfo.html('<strong>' + errorMessage + '</strong>');
upload.elements.$progressBar.val(null);
},
success: function (data) {
if (data.hasOwnProperty('success') && data.success === false) {
upload.elements.$progressBar.val(null);
upload.elements.$statusInfo.html('<strong>Server-Fehler: ' + data.error + '</strong>');
return;
}
if (!data.hasOwnProperty('file') && !data.file.hasOwnProperty('bytes')) {
alert('Fehlerhafte Antwort vom Server. #1');
return;
}
if (data.file.bytes === false) {
alert('Fehlerhafte Antwort vom Server. #2');
return;
}
var bytesSend = data.file.bytes;
var sizeDone = start + bytesSend;
var sizeTotal = upload.file.size;
var percentDone = Math.floor((sizeDone / sizeTotal) * 100);
upload.elements.$progressBar.val(percentDone);
// Die erste Response vom Server enthält das PHP-Upload-Limit
// => ChunkSize anpassen falls diese über dem PHP-Limit liegt
if (data.hasOwnProperty('uploadLimit')
&& typeof data.uploadLimit === 'number'
&& data.uploadLimit > 0
) {
var uploadLimit = Math.floor(data.uploadLimit / 100 * 95); // 5% als Reserve freihalten
var transferSize = me.calculateBase64SizeFromRawSize(me.options.chunkSize);
if (uploadLimit < transferSize) {
var maxChunkSize = me.calculateRawSizeFromBase64Size(uploadLimit);
me.options.chunkSize = maxChunkSize;
console.warn('ChunkedUpload: PHP upload limit is ' + data.uploadLimit + ' bytes.');
console.warn('ChunkedUpload: Chunk size set to ' + maxChunkSize + ' bytes.');
}
}
if (offset < sizeTotal) {
me.uploadFileChunk(upload, offset);
} else {
me.finishUpload(upload.id);
}
}
});
};
// Datei einlesen starten
upload.reader.readAsDataURL(chunkBlob);
},
/**
* Erfolgreichen Upload abschließen
*
* @param {String} uploadId
*/
finishUpload: function (uploadId) {
if (me.storage.uploads.hasOwnProperty(uploadId)) {
var upload = me.storage.uploads[uploadId];
upload.elements.$statusInfo.html('Upload erfolgreich');
upload.elements.$actionCell.html('');
upload.status = STATUS.FINISHED;
// Callback aufrufen
var fileInfo = new FileInfo(upload.id, upload.file.name, upload.file.type, upload.file.size);
me.options.fileComplete(fileInfo);
}
},
/**
* Upload als fehlerhaft markieren
*
* @param {String} uploadId
* @param {String} errorMessage
*/
displayUploadError: function (uploadId, errorMessage) {
if (me.storage.uploads.hasOwnProperty(uploadId)) {
var upload = me.storage.uploads[uploadId];
upload.elements.$statusInfo.html('Upload-Fehler: ' + errorMessage);
upload.elements.$actionCell.html('');
upload.status = STATUS.FAILURE;
}
},
/**
* Datei-Tabelle erzeugen
*/
createFilesContainer: function () {
var template = '<table>';
if(me.options.upload.view === 'sidebar') {
template +=
'<thead><th></th><th></th>' +
'<tbody></tbody>' +
'</table>';
}
else {
template +=
'<thead><th align="left">Dateiname</th><th>Gr&ouml;&szlig;e</th>' +
'<th>Fortschritt</th><th>Status</th><th>Aktionen</th></tr></thead>' +
'<tbody></tbody>' +
'</table>';
}
var $list = $(template).hide();
var $filesContainer = $(me.options.filesContainer);
if ($filesContainer.length === 0) {
$filesContainer = $('<div>').insertAfter(me.storage.$fileInput);
}
$filesContainer.append($list);
$filesContainer.addClass('chunked-file-upload-container');
me.storage.$filesContainer = $filesContainer;
me.storage.$filesList = $list.find('tbody');
},
/**
* HTML5 File-API vorhanden? Oder Uralt-Browser?
*
* @return {boolean}
*/
isFileApiAvailable: function () {
return typeof window.File !== 'undefined' &&
typeof window.FileList !== 'undefined' &&
typeof window.FileReader !== 'undefined';
},
/**
* Zufällige ID generieren
*
* @return {string}
*/
generateRandomId: function () {
return 'chunked_upload_' + Math.floor(Math.random() * Math.floor(9999999999));
},
/**
* @param {string|number} value
*
* @return {string}
*/
formatBytes: function (value) {
var bytes = parseInt(value, 10);
if (bytes === 0) {
return '0&nbsp;Bytes';
}
var sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
var exponent = Math.floor(Math.log(bytes) / Math.log(1024));
var decimalString = (bytes / Math.pow(1024, exponent)).toFixed(1) + '';
return decimalString.replace('.', ',') + '&nbsp;' + sizes[exponent];
},
/**
* @param {number} rawSize Größe der Binärdaten (in Bytes)
*
* @return {number} Größe in Bytes wenn Base64-kodiert
*/
calculateBase64SizeFromRawSize: function (rawSize) {
return Math.floor(rawSize / 3 * 4);
},
/**
*
* @param {number} encodedSize Größe eines Base64-kodierten Strings (in Bytes)
*
* @return {number} Größe in Bytes nach Base64-Dekodierung
*/
calculateRawSizeFromBase64Size: function (encodedSize) {
return Math.floor(encodedSize / 4 * 3);
}
};
/**
* @param {string} id Unique id; example: 'chunked_upload_2377390993'
* @param {string} name file name
* @param {string} type Mime type
* @param {number} size File size in bytes
* @constructor
*/
var FileInfo = function (id, name, type, size) {
this.id = id;
this.name = name;
this.type = type;
this.size = size;
};
me.init($elem, options);
/**
* Return public api
*/
return {};
};
// Dokumentation: Siehe Dateianfang
$.fn.chunkedUpload = function (options) {
return this.each(function () {
var $elem = $(this);
if (!$elem.data('chunkedUpload')) {
var api = new ChunkedUpload(this, options);
$elem.data('chunkedUpload', api);
}
});
};
}(jQuery));
@@ -0,0 +1,32 @@
<?php
namespace Xentral\Widgets\ClickByClickAssistant;
class Bootstrap
{
/**
* @return array
*/
public static function registerJavascript()
{
return [
'ClickByClickAssistant' => [
'./classes/Widgets/ClickByClickAssistant/www/js/click_by_click_assistant.js',
],
];
}
/**
* @return array
*/
public static function registerStylesheets()
{
return [
'ClickByClickAssistant' => [
'./classes/Widgets/ClickByClickAssistant/www/css/click_by_click_assistant.css',
],
];
}
}
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Xentral\Widgets\ClickByClickAssistant\Exception;
use Xentral\Core\Exception\ComponentExceptionInterface;
interface ClickByClickAssistantExceptionInterface extends ComponentExceptionInterface
{
}
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Xentral\Widgets\ClickByClickAssistant\Exception;
use InvalidArgumentException as SplInvalidArgumentException;
class InvalidArgumentException extends SplInvalidArgumentException implements ClickByClickAssistantExceptionInterface
{
}
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace Xentral\Widgets\ClickByClickAssistant;
use Xentral\Widgets\ClickByClickAssistant\Exception\InvalidArgumentException;
final class VueUtil
{
/**
* @param array $array
*
* @return array
*/
public static function keyValueArrayToVueOptions($array): array
{
self::ensureScalarKeyValueArray($array);
$ret = [];
foreach ($array as $value => $text) {
$ret[] = ['value' => (string)$value, 'text' => (string)$text,];
}
return $ret;
}
/**
* @param array[] $pageArray
*
* @return array
*/
public static function getInputNamesFromVuePages($pageArray): array
{
self::ensureArray($pageArray);
$ret = [];
foreach ($pageArray as $page) {
if (empty($page['inputs'])) {
continue;
}
self::ensureArray($page['inputs']);
foreach ($page['inputs'] as $input) {
if (isset($input['name'])) {
$ret[] = $input['name'];
}
}
}
return $ret;
}
/**
* @param mixed $value
*
* @throws InvalidArgumentException
*
* @return void
*/
private static function ensureArray($value): void
{
$type = gettype($value);
if ($type !== 'array') {
throw new InvalidArgumentException(sprintf('Wrong type "%s". Only "array" is allowed.', $type));
}
}
/**
* @param mixed $value
*
* @throws InvalidArgumentException
*
* @return void
*/
private static function ensureScalarKeyValueArray($value): void
{
self::ensureArray($value);
foreach ($value as $key => $val) {
if ($val !== null && !is_scalar($val)) {
$type = gettype($val);
throw new InvalidArgumentException(sprintf('Wrong type "%s". Only scalar types ar allowed.', $type));
}
}
}
}
@@ -0,0 +1,514 @@
:root {
--main-icon-size: 65px;
}
/**
xentral corp colors:
blueish #5a63ee
pink #e66dcb
green #29e7a1
cyan #27e3e5
**/
.click-by-click-assistant {
position: fixed;
z-index: 9998;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: table;
}
.click-by-click-assistant .header-icon {
width: var(--main-icon-size);
height: var(--main-icon-size);
margin: 0 auto 12px auto;
}
.click-by-click-assistant .survey-icon {
background: url('../themes/new/images/question-circle.svg');
background-size: var(--main-icon-size);
}
.click-by-click-assistant .thanks-icon {
background: url('../themes/new/images/thanks-smiley.svg');
background-size: var(--main-icon-size);
}
.click-by-click-assistant .password-icon {
background: url('../themes/new/images/password-icon.svg');
background-size: var(--main-icon-size);
}
.click-by-click-assistant .add-person-icon {
background: url('../themes/new/images/add-person-icon.svg');
background-size: var(--main-icon-size);
}
.click-by-click-assistant .consultant {
width: 107px;
height: 107px;
margin-top: -85px;
border-radius: 100px;
background: url('../themes/new/images/consultant.jpg') center;
background-size: 120px;
}
.click-by-click-assistant h2,
.click-by-click-assistant h3 {
line-height: 22px;
color: #25233A;
opacity: 0.7;
text-align: center;
font-style: normal;
}
.click-by-click-assistant .h2{
font-size: 18px;
}
.click-by-click-assistant .h3{
font-size: 14px;
}
.click-by-click-assistant p {
font-weight: normal;
font-size: 14px;
line-height: 17px;
opacity: 0.7;
display: block;
margin: 0 auto;
padding-top: 4px;
padding-bottom: 10px;
text-align: center;
font-style: normal;
}
.click-by-click-assistant p.page-text{
line-height: 22px;
text-align: left;
}
.click-by-click-assistant .wrapper {
display: table-cell;
vertical-align: middle;
}
.click-by-click-assistant .errorMsg {
text-align: center;
padding-top: 15px;
color: #f44336;
}
.click-by-click-assistant .container {
width: 600px;
margin: 0 auto;
box-sizing: border-box;
position: relative;
}
.click-by-click-assistant .app-close-button {
cursor: pointer;
position: absolute;
right: 5px;
top: 0;
width: 50px;
height: 50px;
background: url('../themes/new/images/icon-close.svg') no-repeat center;
background-size: 10px;
}
.click-by-click-assistant .page {
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.33);
background-color: #fff;
}
.click-by-click-assistant .media-youtube {
width: 100%;
min-height: 337px;
}
.click-by-click-assistant .media-image {
width: 100%;
}
.click-by-click-assistant .page-content {
padding: 35px 30px;
}
.click-by-click-assistant .container .cta {
position: relative;
height: 40px;
padding: 10px 50px;
margin-top: 20px;
background-color: var(--button-primary-background);
}
.click-by-click-assistant .container .button.center {
display: block;
margin-left: auto;
margin-right: auto;
}
.click-by-click-assistant .flex-container {
width: 100%;
display: flex;
justify-content: space-between;
flex-wrap: wrap;
flex-direction: row;
}
.click-by-click-assistant .survey-button-container {
box-sizing: border-box;
margin: 10px 5px 0 5px;
width: calc(1 / 3 * 100% - 10px);
}
.click-by-click-assistant label.button-secondary {
font-size: 14px;
width: 100%;
display: inline-block;
text-align: center;
height: 41px;
line-height: 41px;
padding: 0;
color: var(--button-secondary-color);
border: 1px solid rgba(128, 128, 128, 0.2);
box-shadow: 5px 5px 15px rgba(128, 128, 128, 0.05);
font-style: normal;
font-weight: normal;
margin: 0;
}
.click-by-click-assistant label.button-secondary:hover {
border: 1px solid var(--button-secondary-border-color);
background-color: var(--button-secondary-background);
color: var(--button-secondary-color);
}
input[type="checkbox"]:checked + label.button.button-secondary {
background-color: var(--button-secondary-background);
color: var(--button-secondary-color);
}
.click-by-click-assistant input[type="checkbox"] {
display: none;
}
.click-by-click-assistant input::-webkit-textfield-decoration-container {
visibility: hidden;
}
.click-by-click-assistant .next-enter {
opacity: 0;
transform: translate3d(100px, 0, 0);
}
.click-by-click-assistant .next-enter-active,
.click-by-click-assistant .next-leave-active {
transition: 0.2s cubic-bezier(1.0, 0.5, 0.8, 1.0);
}
.click-by-click-assistant .next-leave-to {
opacity: 0;
transform: translate3d(-100px, 0, 0);
}
.click-by-click-assistant .prev-enter {
opacity: 0;
transform: translate3d(-100px, 0, 0);
}
.click-by-click-assistant .prev-enter-active,
.click-by-click-assistant .prev-leave-active {
transition: 0.2s cubic-bezier(1.0, 0.5, 0.8, 1.0);
}
.click-by-click-assistant .prev-leave-to {
opacity: 0;
transform: translate3d(100px, 0, 0);
}
.click-by-click-assistant .fade-enter-active, .fade-leave-active {
transition: opacity .3s;
}
.click-by-click-assistant .fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */
{
opacity: 0;
}
/**
Input Styles
*/
.click-by-click-assistant .app-row-container {
width: 100%;
}
.click-by-click-assistant .app-row-container .add-row,
.click-by-click-assistant .link {
color: #87B668;
text-decoration: underline;
font-size: 12px;
line-height: 15px;
margin: 30px auto;
min-width: 100px;
text-align: center;
cursor: pointer;
}
.click-by-click-assistant .app-row-container .add-row a,
.click-by-click-assistant .link a {
color: #87B668;
}
.click-by-click-assistant .app-row-container .add-row.inactive {
cursor: auto;
pointer-events: none;
color: #b7b7be;
}
.click-by-click-assistant .app-input-row {
width: 100%;
position: relative;
display: flex;
justify-content: space-between;
flex-wrap: nowrap;
flex-direction: row;
padding-left: 15px;
}
.click-by-click-assistant .app-input-row.reduced-width {
width: 60%;
min-width: 200px;
margin: 0 auto;
padding-left: 0;
}
.click-by-click-assistant .app-input-row .remove-row {
cursor: pointer;
align-self: flex-end;
flex: 0 0 30px;
height: 83px;
background: url('../themes/new/images/remove-icon.svg') no-repeat;
background-size: 15px 12px;
background-position-x: 10px;
background-position-y: 47px;
}
.click-by-click-assistant .app-input-row .app-row-valid {
position: absolute;
height: 15px;
width: 15px;
bottom: 23px;
left: -17px;
}
.click-by-click-assistant .app-input-row .app-row-valid.icon-ok {
background: url('../themes/new/images/icon-ok.svg') no-repeat center;
background-size: 11px 8px;
}
.click-by-click-assistant .app-input-row .app-row-valid.checkbox {
bottom: 10px;
}
.click-by-click-assistant .container a {
color: #929292;
}
.click-by-click-assistant .app-input {
flex-grow: 1;
position: relative;
white-space: nowrap;
padding-right: 12px;
box-sizing: border-box;
margin: 35px auto 10px auto;
transition: 0.2s ease width;
-moz-transition: 0.2s ease width;
-webkit-transition: 0.2s ease width;
}
.click-by-click-assistant .app-input input[type="checkbox"] {
width: 10%;
}
.click-by-click-assistant .app-input-row .app-input:last-of-type {
padding-right: 0;
}
.click-by-click-assistant .app-input.input-error *:not(input) {
color: #f44336;
}
.click-by-click-assistant .app-input.input-error input,
.click-by-click-assistant .app-input.input-error select {
border-bottom-color: #f44336;
}
.click-by-click-assistant .app-input .input-error {
position: absolute;
bottom: -15px;
left: 5px;
opacity: .8;
}
.click-by-click-assistant .app-input .reveal {
cursor: pointer;
position: absolute;
right: 0;
top: 0;
display: block;
width: 30px;
height: 38px;
background: url('../themes/new/images/icon-invisible.svg');
background-size: 15px 12px;
background-repeat: no-repeat;
background-position: center;
opacity: .3;
border: none;
}
.click-by-click-assistant .app-input input,
.click-by-click-assistant .app-input select {
width: 100%;
font-style: normal;
font-weight: normal;
font-size: 14px;
line-height: 17px;
color: #25233A;
padding: 10px 10px 10px 5px;
display: block;
border: none;
border-bottom: 1px solid #d9d9d9;
border-radius: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.click-by-click-assistant .app-input select {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
width: 100%;
}
.click-by-click-assistant .app-input select::-ms-expand {
display: none;
}
.click-by-click-assistant .app-input input:focus,
.click-by-click-assistant .app-input select:focus {
outline: none;
}
.click-by-click-assistant .app-input label {
color: #b7b7be;
font-size: 14px;
line-height: 17px;
font-weight: normal;
position: absolute;
pointer-events: none;
left: 5px;
top: 10px;
transition: 0.2s ease all;
-moz-transition: 0.2s ease all;
-webkit-transition: 0.2s ease all;
}
.click-by-click-assistant .app-input input[type="checkbox"] ~ label {
left: 10%;
top: 0;
pointer-events: all;
cursor: pointer;
line-height: 14px;
}
.click-by-click-assistant .app-input.select:after {
content: "";
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 5px solid;
position: absolute;
top: 50%;
right: 0;
margin-top: -2.5px;
pointer-events: none;
color: #e6e6e6;
}
.click-by-click-assistant .app-input input:focus ~ label,
.click-by-click-assistant .app-input input:valid ~ label,
.click-by-click-assistant .app-input select.hasSelected ~ label,
.click-by-click-assistant .app-input input.hasValue ~ label,
.click-by-click-assistant .app-input select:valid ~ label {
top: -15px;
font-size: 10px;
}
.click-by-click-assistant .app-input input[type="checkbox"]:focus ~ label,
.click-by-click-assistant .app-input input[type="checkbox"]:valid ~ label,
.click-by-click-assistant .app-input input[type="checkbox"].hasValue ~ label {
top: 0;
font-size: 14px;
}
.click-by-click-assistant .app-pagination {
text-align: center;
margin: 20px 0 0 0;
}
.click-by-click-assistant .app-pagination div {
display: inline-block;
background-color: #d9d9d9;
width: 5px;
height: 5px;
border-radius: 5px;
margin: 0 2px;
vertical-align: middle;
transition: 0.2s ease all;
-moz-transition: 0.2s ease all;
-webkit-transition: 0.2s ease all;
}
.click-by-click-assistant .app-pagination div.active {
background-color: #a0a0a0;
}
.click-by-click-assistant .spinner {
position: absolute;
width: 20px;
height: 20px;
clear: both;
right: 15px;
top: 9px;
}
/* Spinner Circle Rotation */
.click-by-click-assistant .spinner-circle {
border: 3px rgba(255, 255, 255, 0.25) solid;
border-top: 3px rgba(255, 255, 255, 1) solid;
border-radius: 50%;
-webkit-animation: spCircRot .6s infinite linear;
animation: spCircRot .6s infinite linear;
}
@-webkit-keyframes spCircRot {
from {
-webkit-transform: rotate(0deg);
}
to {
-webkit-transform: rotate(359deg);
}
}
@keyframes spCircRot {
from {
transform: rotate(0deg);
}
to {
transform: rotate(359deg);
}
}
@@ -0,0 +1,655 @@
Vue.component('click-by-click-assistant', {
props: ['pages', 'allowclose', 'pagination'],
data: function(){
return {
activePage: 0,
currentTransition:'',
dataStorage: []
}
},
template: '<div class="click-by-click-assistant"><div class="wrapper"><div class="container">' +
'<div v-if="allowclose" class="app-close-button" @click="$emit(\'close\')"></div>' +
'<transition :name="currentTransition" mode="out-in">' +
/** DEFAULT TEXT PAGE **/
'<div class="page" v-for="(page, index) in pages" ' +
'v-if="page.type === \'defaultPage\' && activePage === index" ' +
':data-pageIndex="index" ' +
':key="index">' +
'<app-media v-if="page.headerMedia" :media="page.headerMedia"></app-media>' +
'<div class="page-content">' +
'<div v-if="!page.headerMedia && page.icon" class="header-icon" :class="page.icon"></div>' +
'<h2 v-if="page.headline" v-html="page.headline"></h2>'+
'<h3 v-if="page.subHeadline" v-html="page.subHeadline"></h3>'+
'<p class="page-text" v-if="page.text" v-html="page.text"></p>'+
'<div class="flex-container" v-if="page.link">'+
'<div v-if="page.link" class="link">'+
'<a class="link" :href="page.link.link" >{{ page.link.title }}</a>'+
'</div>' +
'</div>'+
'<button v-if="button.action === \'next\'" ' +
'v-for="button in page.ctaButtons" ' +
'class="button button-primary cta center" ' +
'@click="changePage(\'next\')">{{ button.title }}</button>'+
'<button v-if="!button.link && button.action === \'close\'" ' +
'v-for="button in page.ctaButtons" ' +
'class="button button-primary cta center" ' +
'@click="$emit(\'close\')">{{ button.title }}</button>'+
'<button v-if="!button.link && button.action === \'completeStep\'" ' +
'v-for="button in page.ctaButtons" ' +
'class="button button-primary cta center" ' +
'@click="$emit(\'completeStep\')">{{ button.title }}</button>'+
'<button v-if="button.link && button.action === \'close\'" ' +
'v-for="button in page.ctaButtons" ' +
'class="button button-primary cta center" ' +
'@click="link(button.link)">{{ button.title }}</button>'+
'<app-pagination v-if="pagination" :pages="pages" :index="index"></app-pagination>' +
'</div>'+
'</div>' +
/** FORM PAGE **/
'<div class="page" v-for="(page, index) in pages" ' +
'v-if="(page.type === \'form\' || page.type === \'survey\') && activePage === index" ' +
':data-pageIndex="index" :key="index">' +
'<app-media v-if="page.headerMedia" :media="page.headerMedia"></app-media>' +
'<div class="page-content">' +
'<div v-if="!page.headerMedia && page.icon" class="header-icon" :class="page.icon"></div>' +
'<h2 v-html="page.headline"></h2>'+
'<p v-if="page.subHeadline" v-html="page.subHeadline"></p>'+
'<app-form :page="page"></app-form>' +
'<app-pagination v-if="pagination" :pages="pages" :index="index"></app-pagination>' +
'</div>' +
'</div>' +
'</transition>' +
'</div></div></div>',
mounted: function(){
var self = this;
self.saveDataRequiredForSubmit();
self.$on('completeStep', function(){
if(WizardContainer !== undefined){
WizardContainer.completeStep();
}
self.$emit('close');
});
},
methods: {
/**
* @param {string} direction
*/
changePage: function(direction){
if(direction !== 'back' && direction !== 'next'){
return;
}
this.activePage = direction === 'next' ? this.activePage + 1 : this.activePage - 1;
this.currentTransition = direction;
},
link: function(link)
{
window.location.href = link;
},
/**
* saves all data that was defined on the building JSON file in order to submit it later
*/
saveDataRequiredForSubmit: function(){
var current;
for(var i = 0; i < this.pages.length; i++){
current = this.pages[i].dataRequiredForSubmit;
if(current === undefined || current.length === 0){
return;
}
this.setToStorage(current);
}
},
/**
* @param {Object} object
*/
setToStorage: function(object){
this.dataStorage.push(object);
},
/**
*
* @returns {Object}
*/
getStorage: function(){
return this.dataStorage;
},
/**
* clear storage, but keeps vue listener on this.dataStorage
*/
clearStorage: function(){
for (var member in this.dataStorage) {
delete this.dataStorage[member];
}
}
}
});
Vue.component('app-form',{
props: ['page'],
data: function () {
return {
rowId: 0,
surveyChoice: [],
showSurveyError: false,
formValid: true,
formWasValidated: false,
loading: false,
errorMsg: 'Bitte überprüfe die Eingabefelder'
}
},
template:
'<form @submit.prevent="processForm" novalidate>' +
'<div class="flex-container" v-for="(row, rowIndex) in page.form" :key="row.id">' +
'<app-input-row v-if="row.inputs !== undefined && row.inputs.length > 0" ' +
'ref="row" :row="row" ' +
':hasSiblings="page.form.length > 1" ' +
'@deleteme="removeInputRow(rowIndex)"></app-input-row>' +
'<span v-else-if="row.surveyButtons !== undefined && row.surveyButtons.length > 0" ' +
'class="survey-button-container" ' +
'v-for="button in row.surveyButtons">'+
'<input type="checkbox" :id="button.value" name="data" :value="button.value" v-model="surveyChoice"/>'+
'<label :for="button.value" class="button button-secondary" > {{ button.title }} </label>' +
'</span>' +
'</div>' +
'<div class="flex-container" v-if="page.link">'+
'<div v-if="page.link" class="add-row">'+
'<a class="link" :href="page.link.link" >{{ page.link.title }}</a>'+
'</div>' +
'</div>'+
'<transition name="fade">' +
'<div v-if="page.errorMsg && showSurveyError && surveyChoice.length === 0" ' +
'class="errorMsg">{{ page.errorMsg }}</div>'+
'<div v-if="formWasValidated && !formValid" class="errorMsg"> {{ errorMsg }}</div>'+
'</transition>' +
'<button v-for="button in page.ctaButtons" ' +
':type="button.action" class="button button-primary cta center">{{ button.title }}' +
'<app-spinner v-if="loading"></app-spinner></button>' +
'</form>',
methods:{
/**
* @param {Object} row
*/
addInputRow: function(row){
this.rowId++
row.id = this.rowId;
for(var k = 0; k < row.inputs.length; k++){
row.inputs[k].name += this.rowId;
}
this.page.form.push(row);
for(var i = 0; i < this.page.form.length -1; i++){
this.page.form[i].add.allow = false;
}
if(row.add.maximum === this.page.form.length){
this.allowAddOnLastRow(false);
}
},
/**
* @param {number} index
*/
removeInputRow: function(index){
if(this.page.form.length <= 1){
return;
}
this.page.form.splice(index, 1);
this.allowAddOnLastRow(true);
},
/**
* @param {string} decision
*/
allowAddOnLastRow: function(decision){
var lastIndex = this.page.form.length - 1;
this.page.form[lastIndex].add.allow = decision;
},
/**
* @param {Object} e
*/
processForm: function(e){
this.validateForm();
if(!this.formValid){
return;
}
if(!this.page.submitType){
throw new Error("Please define submitType in your JSON");
}
if(this.page.submitType === "save"){
this.$parent.setToStorage(this.filterDataFromSubmitEvent(e));
this.$parent.changePage("next");
return;
}
this.submitForm(e);
},
validateForm: function(){
if(this.page.submitType === 'survey'){
this.formValid = this.surveyChoice.length !== 0;
this.formWasValidated = true;
if(!this.formValid){
this.showSurveyError = true;
}
} else {
this.formValid = this.requiredRowsValid();
}
},
/**
* Checks if the required rows are valid
* @returns {boolean}
*/
requiredRowsValid: function(){
if(this.$refs === undefined){
console.error("Please define ref on child component");
return false;
}
if(this.$refs.row === undefined) {
return true;
}
var current;
for(var i = 0; i < this.$refs.row.length; i++){
current = this.$refs.row[i];
// case 1: if row has not been validated (no user input), form is valid in regard of this row
if(!current.rowWasValidated){
this.formWasValidated = false;
return true;
}
// case 2: if row is invalid, form is not valid
// rowValid only includes required inputs (filtered out on row component)
if(!current.rowValid){
this.formWasValidated = true;
return false;
}
}
return true; // if case1 or case2 didn't match, form valid
},
/**
* @param {Event} e
*/
submitForm: function(e){
var request = new XMLHttpRequest(),
self = this,
data,
responseJson;
data = this.prepareSubmitData(e);
request.open("POST", this.page.submitUrl + '', true);
request.addEventListener('load', function(event) {
if (request.status >= 200 && request.status < 300) {
console.log("POST " + request.statusText + " status: " + request.status);
responseJson = JSON.parse(request.responseText);
if(responseJson.page !== undefined) {
self.$parent.pages.push(responseJson.page);
}
self.$parent.clearStorage();
if(responseJson.dataRequiredForSubmit !== undefined){
self.$parent.setToStorage(responseJson.dataRequiredForSubmit);
}
self.$parent.changePage("next");
self.loading = false;
} else {
console.warn(request.statusText, request.responseText);
self.loading = false;
self.formValid = false;
self.formWasValidated = true;
responseJson = JSON.parse(request.responseText);
if(responseJson.error !== undefined) {
self.errorMsg = responseJson.error;
}
else {
self.errorMsg = 'Ooops, da ist etwas schief gelaufen. Bitte versuche es erneut.';
}
if(responseJson.dataRequiredForSubmit !== undefined){
self.$parent.setToStorage(responseJson.dataRequiredForSubmit);
}
}
});
self.loading = true;
request.send(data);
},
/**
* Combines all available data of all not-submitted pages
*
* @param {Object} e
*
* @returns {FormData}
*/
prepareSubmitData: function(e){
var submitData = new FormData(),
filteredEventData,
storageData;
filteredEventData = this.filterDataFromSubmitEvent(e);
storageData = JSON.parse(JSON.stringify(this.$parent.getStorage()));
if(storageData !== undefined && storageData.length > 0){
for(var i = 0; i < storageData.length; i++){
for(var key in storageData[i]){
submitData.append(key, storageData[i][key]);
}
}
}
if(filteredEventData !== undefined){
for(var filteredEventDataKey in filteredEventData){
submitData.append(filteredEventDataKey, filteredEventData[filteredEventDataKey]);
}
}
return submitData;
},
/**
* Serializes all data from a form submit event
*
* @param e
*
* @returns {Object}
*/
filterDataFromSubmitEvent: function(e){
var data = {},
checkedInSurvey = [],
current;
for(var i = 0; i < e.target.length; i++){
current = e.target[i];
if(current.tagName === "button" || current.tagName === "BUTTON"){
continue;
}
if(!current.name){
throw new Error("Please define names for all inputs");
}
if(this.page.type === "survey" && (current.tagName === "input" || current.tagName === "INPUT")){
if(current.checked){
checkedInSurvey.push(current.value);
}
data[current.name] = checkedInSurvey;
} else {
if(current.type === 'checkbox') {
if(current.checked){
data[current.name] = current.value;
}
}
else {
data[current.name] = current.value;
}
}
}
return data;
}
}
});
Vue.component('app-input-row',{
props: ['row', 'hasSiblings'],
data: function(){
return {
rowValid: true,
rowWasValidated: false
}
},
template:
'<div class="app-row-container">' +
'<div class="app-input-row" :class="{\'reduced-width\': row.inputs.length === 1 }">' +
'<div class="app-row-valid" :class="{\'icon-ok\': rowValid && rowWasValidated}"></div>' +
'<app-input ' +
'v-for="(input, inputIndex) in row.inputs" ' +
':type="input.type" ' +
':validation="input.validation" ' +
':customErrorMsg="input.customErrorMsg" ' +
':name="input.name" ' +
':label="input.label"' +
':value="input.value"' +
':connectedTo="input.connectedTo"' +
':options="input.options"' +
'ref="input"'+
':key="inputIndex"></app-input>' +
'<div v-if="row.removable && hasSiblings" @click="$emit(\'deleteme\')" class="remove-row"></div>' +
'</div>' +
'<div v-if="row.add && row.add.allow" @click="addRow" class="add-row">{{ row.add.text }}</div>' +
'<div v-if="row.link" class="add-row"><a class="link" :href="row.link.link" >{{ row.link.title }}</a></div>' +
'</div>',
methods:{
addRow: function(){
var newRow = JSON.parse(JSON.stringify(this.row)); // removes vue observable and makes it possible to change
this.$parent.addInputRow(newRow);
},
validateRow: function(){
this.rowValid = this.requiredInputsValid();
this.rowWasValidated = true;
if(this.rowValid){
this.$parent.validateForm();
}
},
/**
* Checks if the required Inputs are valid
*
* @returns {boolean}
*/
requiredInputsValid: function(){
if(this.$refs === undefined){
throw new Error("Please define ref on child component");
}
var valid = true,
current;
for(var i = 0; i < this.$refs.input.length; i++){
current = this.$refs.input[i];
if(!current.validation){
valid = true;
continue;
}
valid = current.validation && current.valid && current.wasValidated;
// row is invalid after first invalid input
if(!valid){
return false;
}
}
return valid;
}
}
});
Vue.component('app-input', {
props: ['type', 'validation', 'name', 'label', 'customErrorMsg', 'options', 'value', 'connectedTo'],
data: function () {
return {
inputValue: this.value ? this.value : '',
inputType: this.type,
inputErrorMsg: undefined,
valid: true,
wasValidated: false
}
},
template:
'<div v-if="type === \'select\'" class="app-input select" :class="{\'input-error\': !valid }">' +
'<select :id="name" :name="name" v-model="inputValue" :class="{\'hasSelected\': inputValue.length > 0 }" ' +
'@change="validateInput" >' +
'<option v-for="(option, index) in options" :value="option.value" :key="index">{{ option.text }}</option>' +
'</select>' +
'<label :for="name">{{ label }} <span v-if="validation"> (Pflichtfeld)</span></label>' +
'</div>'+
'<div v-else class="app-input" :class="{\'input-error\': !valid}">' +
//'<input style="display: none" type="password" />' +
'<input :type="inputType" :id="name" :name="name" v-model="inputValue" ' +
':class="{\'hasValue\': inputValue.length > 0 }" ' +
'@blur="validateInput" autocomplete="off" required />' +
'<div v-if="type === \'password\'" class="reveal" @click="togglePasswordVisibility"></div>' +
'<label :for="name">{{ label }} <span v-if="validation"> (Pflichtfeld)</span></label>' +
'<transition name="fade">' +
'<div v-if="!valid && inputErrorMsg" class="input-error"> {{ inputErrorMsg }}</div>' +
'</transition>'+
'</div>',
mounted: function(){
var self = this;
// listens to compare request "broadcast" from other component
self.$root.$on('compareConnected', function(data){
if(self.name !== data.connectedTo) {
return;
}
// "broadcasts" to every component listening
self.$root.$emit('comparisonResult', {
requestingInput: data.requestingInput.name,
valid: self.inputValue === data.requestingInput.inputValue && self.valid
})
});
self.$root.$on('comparisonResult', function(result){
if(self.name === result.requestingInput){
self.valid = result.valid;
}
});
},
methods: {
validateInput: function(){
if((this.inputValue.length === 0 && !this.wasValidated) || !this.validation){
// input is valid if it has a value and wasn't validated before (inputs do not get validated on page render)
// or if it's not necessary to validate
this.valid = true;
return;
}
switch(this.type){
case "email":
var regex = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
this.valid = regex.test(this.inputValue);
this.inputErrorMsg = this.customErrorMsg || "Adresse nicht gültig";
break;
case "text":
this.valid = this.inputValue.length >= 2;
this.inputErrorMsg = this.customErrorMsg || "Mindestens zwei Zeichen";
break;
case "password":
// "broadcasting" event to listening components
// In this case we compare if passwords match in connected fields -> "connectedTo" option in JSON
if(this.connectedTo !== undefined){
this.$root.$emit('compareConnected', {
connectedTo: this.connectedTo,
requestingInput: {
name: this.name,
inputValue: this.inputValue
}
});
this.inputErrorMsg = this.customErrorMsg || "Bitte wiederholen Sie das Passwort";
} else {
this.valid = this.inputValue.length >= 4;
this.inputErrorMsg = this.customErrorMsg || "Mindestens vier Zeichen";
}
break;
case "select":
// it's "selected/changed" (event) so it always has a valid value
break;
default:
break;
}
this.wasValidated = true;
this.$parent.validateRow();
},
togglePasswordVisibility: function(){
this.inputType = this.inputType === 'password' ? 'text' : 'password';
}
}
});
Vue.component('app-pagination', {
props: ["pages", "index"],
template: '' +
'<div class="app-pagination">' +
'<div v-for="(dot, dotIndex) in pages" :class="{\'active\': index === dotIndex}"></div>' +
'</div>'
});
Vue.component('app-spinner',{
template: '<div class="spinner spinner-circle"></div>'
});
Vue.component('app-media',{
props: ["media"],
template:
'<div>' +
'<iframe v-if="media.type === \'video\'"' +
'class="media-youtube" ' +
':src="media.link + \'?rel=0\'" ' +
'frameborder="0" ' +
'allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture" ' +
'allowfullscreen>' +
'</iframe>' +
'<img ' +
'v-if="media.type === \'image\'"' +
'class="media-image"' +
':src="media.link">' +
'<img/>' +
'</div>'
});
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace Xentral\Widgets\DataTable;
use Xentral\Core\DependencyInjection\ContainerInterface;
use Xentral\Widgets\DataTable\Service\DataTableRequestHandler;
use Xentral\Widgets\DataTable\Service\DataTableService;
final class Bootstrap
{
/**
* @return array
*/
public static function registerServices()
{
return [
'DataTableService' => 'onInitDataTableService',
'DataTableRequestHandler' => 'onInitDataTableRequestHandler',
];
}
/**
* @param ContainerInterface $container
*
* @return DataTableService
*/
public static function onInitDataTableService(ContainerInterface $container)
{
$factory = new DataTableFactory($container);
return $factory->createDataTableService();
}
/**
* @param ContainerInterface $container
*
* @return DataTableRequestHandler
*/
public static function onInitDataTableRequestHandler(ContainerInterface $container)
{
$factory = new DataTableFactory($container);
return $factory->createDataTableRequestHandler();
}
}
+443
View File
@@ -0,0 +1,443 @@
<?php
namespace Xentral\Widgets\DataTable\Column;
use JsonSerializable;
use Xentral\Widgets\DataTable\Exception\InvalidArgumentException;
use Xentral\Widgets\DataTable\Feature\ResponsiveFeature;
final class Column implements JsonSerializable
{
/** @var string ALIGN_LEFT */
const ALIGN_LEFT = 'left';
/** @var string ALIGN_RIGHT */
const ALIGN_RIGHT = 'right';
/** @var string ALIGN_CENTER */
const ALIGN_CENTER = 'center';
/** @var string ALIGN_JUSTIFY */
const ALIGN_JUSTIFY = 'justify';
/** @var array $validAlignments */
public static $validAlignments = [
self::ALIGN_LEFT,
self::ALIGN_RIGHT,
self::ALIGN_CENTER,
self::ALIGN_JUSTIFY,
];
/** @var string $name */
private $name;
/** @var string $title */
private $title;
/** @var bool $visible */
private $visible;
/** @var bool $sortable */
private $sortable;
/** @var bool $searchable */
private $searchable;
/** @var bool $exportable */
private $exportable;
/** @var bool $fixed */
private $fixed;
/** @var string $alignment */
private $alignment;
/** @var string|null $dbColumn */
private $dbColumn;
/** @var string|null $width */
private $width;
/** @var callable|null $formatter */
private $formatter;
/** @var array $properties */
private $properties = [];
/** @var array $cssClasses CSS classes */
private $cssClasses = [];
/**
* @param string $name
* @param string $title
* @param string $align [left|right|center|justify]
* @param string|null $width Column width as CSS value (e.g 20%, 3em, 55px)
* @param bool $visible Is column currently visible? Visibility can be changed at runtime
* @param bool $sortable
* @param bool $searchable
* @param bool $exportable
* @param bool $fixed If true, column is always visible and visibility can not be changed at runtime
* * Fixed columns can't be hidden (ResponsiveFeature, ColumnVisibilityFeature)
* * Fixed columns can't be reordered (ColumnReorderFeature)
*
* @throws InvalidArgumentException
*/
public function __construct(
$name,
$title,
$align = 'left',
$width = null,
$visible = true,
$sortable = false,
$searchable = false,
$exportable = false,
$fixed = false
) {
if (empty($name)) {
throw new InvalidArgumentException('Column name can not be empty.');
}
$cleanedName = (string)preg_replace('#[^a-z0-9_]#', '', trim($name));
if ($cleanedName !== $name) {
throw new InvalidArgumentException(sprintf(
'Name "%s" contains illegal characters. Valid characters are: a-z, 0-9 and underscore.',
$name
));
}
$this->name = (string)$name;
$this->title = (string)$title;
$this->visible = (bool)$visible;
$this->sortable = (bool)$sortable;
$this->searchable = (bool)$searchable;
$this->exportable = (bool)$exportable;
$this->fixed = (bool)$fixed;
$this->alignment = (string)$align;
$this->width = $width !== null ? (string)$width : null;
}
/**
* Currently hidden column; can be unhidden
*
* @param string $name
* @param string $title
* @param string $align [left|right|center|justify]
* @param string|null $width Column width as CSS value (e.g 20%, 3em, 55px)
*
* @return Column
*/
public static function hidden($name, $title, $align = 'left', $width = null)
{
return new static($name, $title, $align, $width, false, false, false, false, false);
}
/**
* Visible column; not sortable and not searchable
*
* @param string $name
* @param string $title
* @param string $align [left|right|center|justify]
* @param string|null $width Column width as CSS value (e.g 20%, 3em, 55px)
*
* @return Column
*/
public static function visible($name, $title, $align = 'left', $width = null)
{
return new static($name, $title, $align, $width, true, false, false, true, false);
}
/**
* Visible and sortable column; not searchable
*
* @param string $name
* @param string $title
* @param string $align [left|right|center|justify]
* @param string|null $width Column width as CSS value (e.g 20%, 3em, 55px)
*
* @return Column
*/
public static function sortable($name, $title, $align = 'left', $width = null)
{
return new static($name, $title, $align, $width, true, true, false, true, false);
}
/**
* Visible, sortable und searchable column
*
* @param string $name
* @param string $title
* @param string $align [left|right|center|justify]
* @param string|null $width Column width as CSS value (e.g 20%, 3em, 55px)
*
* @return Column
*/
public static function searchable($name, $title, $align = 'left', $width = null)
{
return new static($name, $title, $align, $width, true, true, true, true, false);
}
/**
* Always visible and with fixed position (for Menu and Selection columns)
*
* - Always visible; Can't be hidden (ColumnVisibilityFeature)
* - Fixed position; Can't be reordered (ColumnReorderFeature)
* - Not searchable
* - Not sortable
*
* @param string $name
* @param string $title
*
* @return Column
*/
public static function fixed($name, $title = '', $align = 'center', $width = null)
{
$fixed = new static($name, $title, $align, $width, true, false, false, false, true);
$fixed->set('responsivePriority', ResponsiveFeature::PRIO_HIGHER);
return $fixed;
}
/**
* @return string
*/
public function getName()
{
return $this->name;
}
/**
* @return string
*/
public function getTitle()
{
return $this->title;
}
/**
* @param string $dbColumn
*/
public function setDbColumn($dbColumn)
{
$this->dbColumn = (string)$dbColumn;
}
/**
* @return string|null
*/
public function getDbColumn()
{
return $this->dbColumn;
}
/**
* @return string
*/
public function getAlignment()
{
return $this->alignment;
}
/**
* @see $validAlignments
*
* @param string $alignment [left|right|center|justify]
*/
public function setAlignment($alignment)
{
if (!in_array($alignment, self::$validAlignments, true)) {
throw new InvalidArgumentException(sprintf(
'Alignment "%s" is not valid. Valid alignments: %s',
$alignment,
implode(', ', self::$validAlignments)
));
}
$this->alignment = $alignment;
}
/**
* @return callable|null
*/
public function getFormatter()
{
return $this->formatter;
}
/**
* @param callable $formatter
*
* @return void
*/
public function setFormatter(callable $formatter)
{
$this->formatter = $formatter;
}
/**
* @return bool
*/
public function isFixed()
{
return $this->fixed;
}
/**
* @return bool
*/
public function isVisible()
{
return $this->visible;
}
/**
* @return bool
*/
public function isSortable()
{
return $this->sortable;
}
/**
* @return bool
*/
public function isExportable()
{
return $this->exportable;
}
/**
* @return bool
*/
public function isSearchable()
{
return $this->searchable;
}
/**
* @param string $property
*
* @return bool
*/
public function has($property)
{
if (isset($this->{$property})) {
return true;
}
return isset($this->properties[$property]);
}
/**
* @param string $property
*
* @return mixed|null
*/
public function get($property)
{
if (isset($this->{$property})) {
return $this->{$property};
}
if (isset($this->properties[$property])) {
return $this->properties[$property];
}
return null;
}
/**
* @param string $property
* @param mixed $value
*
* @return void
*/
public function set($property, $value)
{
if (isset($this->{$property})) {
$this->{$property} = $value;
}
$this->properties[$property] = $value;
}
/**
* @param string $className
*
* @return bool
*/
public function hasCssClass($className)
{
return in_array($className, $this->cssClasses, true);
}
/**
* @param string $className
*
* @return void
*/
public function addCssClass($className)
{
$this->cssClasses[] = trim($className);
$this->cssClasses = array_unique($this->cssClasses);
}
/**
* @param string $className
*
* @return void
*/
public function removeCssClass($className)
{
$classKey = array_search($className, $this->cssClasses, true);
if ($classKey !== false) {
unset($this->cssClasses[$classKey]);
$this->cssClasses = array_values($this->cssClasses);
}
}
/**
* @return array
*/
public function toArray()
{
$result = $this->properties;
$result['data'] = isset($result['data']) ? $result['data'] : $this->name;
$result['name'] = $this->name;
$result['title'] = $this->title;
$result['exportable'] = $this->exportable;
$result['searchable'] = $this->searchable;
$result['orderable'] = $this->sortable;
$result['visible'] = $this->visible;
$result['fixed'] = $this->fixed;
if ($this->fixed === true) {
$result['visible'] = true;
}
// Spalte hat keine Daten; z.B. Menü-Spalte
if ($this->dbColumn === null) {
//$result['data'] = null;
$result['defaultContent'] = isset($result['defaultContent']) ? $result['defaultContent'] : '';
$result['orderable'] = false;
$result['searchable'] = false;
// $result['data'] = $this->name;
// $result['searchable'] = $this->searchable;
}
$cssClasses = $this->cssClasses;
$cssClasses[] = 'dt-' . $this->alignment;
$result['className'] = implode(' ', $cssClasses);
if ($this->width !== null) {
$result['width'] = $this->width;
}
return $result;
}
/**
* @return array
*/
public function jsonSerialize()
{
return $this->toArray();
}
}
@@ -0,0 +1,248 @@
<?php
namespace Xentral\Widgets\DataTable\Column;
use ArrayIterator;
use IteratorAggregate;
use JsonSerializable;
use Traversable;
use Xentral\Widgets\DataTable\Exception\ColumnNameAssignedException;
use Xentral\Widgets\DataTable\Exception\InvalidArgumentException;
class ColumnCollection implements JsonSerializable, IteratorAggregate
{
/** @var array|Column[] $columns */
protected $columns = [];
/**
* @param array|Column[] $columns
*/
public function __construct(array $columns = [])
{
foreach ($columns as $column) {
$this->add($column);
}
}
/**
* @param string $columnName
*
* @return bool
*/
public function has($columnName)
{
return $this->getByName($columnName) !== null;
}
/**
* @param Column $column
*
* @return void
*/
public function add(Column $column)
{
$this->ensureUniqueColumnName($column->getName());
$this->columns[] = $column;
}
/**
* @param Column $newColumn
* @param string $columnNameBefore
*
* @return void
*/
public function addAfter(Column $newColumn, $columnNameBefore)
{
$this->ensureUniqueColumnName($newColumn->getName());
$offset = $this->getColumnIndexByName($columnNameBefore) + 1;
$columnsBefore = array_slice($this->columns, 0, $offset, false);
$columnsAfter = array_slice($this->columns, $offset, null, false);
$this->columns = array_merge($columnsBefore, [$newColumn], $columnsAfter);
}
/**
* @param Column $newColumn
* @param string $columnNameAfter
*
* @return void
*/
public function addBefore(Column $newColumn, $columnNameAfter)
{
$this->ensureUniqueColumnName($newColumn->getName());
$offset = $this->getColumnIndexByName($columnNameAfter);
$columnsBefore = array_slice($this->columns, 0, $offset, false);
$columnsAfter = array_slice($this->columns, $offset, null, false);
$this->columns = array_merge($columnsBefore, [$newColumn], $columnsAfter);
}
/**
* @param string $columnName
*
* @return bool
*/
public function remove($columnName)
{
foreach ($this->columns as $index => $column) {
if ($column->getName() !== $columnName) {
unset($this->columns[$index]);
$this->columns = array_values($this->columns);
return true;
}
}
return false;
}
/**
* @param string $name
*
* @return Column|null
*/
public function getByName($name)
{
foreach ($this->columns as $column) {
if ($column->getName() === $name) {
return $column;
}
}
return null;
}
/**
* @param int $index
*
* @return Column|null
*/
public function getByIndex($index)
{
return isset($this->columns[(int)$index]) ? $this->columns[(int)$index] : null;
}
/**
* @param string $columnName
*
*
* @return int
*/
public function getColumnIndexByName($columnName)
{
$offset = false;
$this->columns = array_values($this->columns);
foreach ($this->columns as $index => $column) {
if ($column->getName() === $columnName) {
$offset = $index;
}
}
if ($offset === false) {
throw new InvalidArgumentException(sprintf('Column name "%s" does not exists.', $columnName));
}
return $offset;
}
/**
* @return array|Column[]
*/
public function all()
{
return $this->columns;
}
/**
* @return array
*/
public function getSearchableDbColumns()
{
$searchable = [];
foreach ($this->columns as $column) {
if ($column->isSearchable()) {
$searchable[] = $column->getDbColumn();
}
}
return $searchable;
}
/**
* @return array|callable[] Array with callables, indexed by column name;
* Empty array if there aren't any formatters
*/
public function getFormatters()
{
$formatters = [];
foreach ($this->columns as $column) {
$colName = $column->getName();
$formatter = $column->getFormatter();
if (!empty($colName) && $formatter !== null) {
$formatters[$colName] = $formatter;
}
}
return $formatters;
}
/**
* @return array
*/
public function toArray()
{
$result = [];
foreach ($this->columns as $column) {
$result[] = $column->toArray();
}
return $result;
}
/**
* @return array
*/
public function jsonSerialize()
{
return $this->toArray();
}
/**
* @return ArrayIterator|Traversable
*/
public function getIterator()
{
return new ArrayIterator($this->columns);
}
/**
* Deep copy object
*
* @return void
*/
public function __clone()
{
foreach ($this->columns as $index => $column) {
$this->columns[$index] = clone $column;
}
}
/**
* @param string $columnName
*
* @throws ColumnNameAssignedException
*
* @return void
*/
protected function ensureUniqueColumnName($columnName)
{
if ($this->has($columnName)) {
throw new ColumnNameAssignedException(sprintf('Column name "%s" is already assigend.', $columnName));
}
}
}
@@ -0,0 +1,142 @@
<?php
namespace Xentral\Widgets\DataTable\Column;
use Closure;
use DateTime;
use Exception;
class ColumnFormatter
{
/**
* @param mixed $ifEmpty
*
* @return Closure
*/
public static function ifEmpty($ifEmpty)
{
return static function ($value) use ($ifEmpty) {
if (empty($value)) {
return $ifEmpty;
}
return $value;
};
}
/**
* @example Format::sprintf('row_id_%s') %s will be replaced with the current value
*
* @param mixed $sprintf
*
* @return Closure
*/
public static function sprintf($sprintf)
{
return static function ($value) use ($sprintf) {
return sprintf($sprintf, $value);
};
}
/**
* @param string $template
*
* @return Closure
*/
public static function template($template)
{
return static function ($value, $rowAssoc) use ($template) {
$templateVars = [];
foreach ($rowAssoc as $assocKey => $assocValue) {
$templateVar = '{' . strtoupper($assocKey) . '}';
$templateVar = str_replace('-', '_', $templateVar);
$templateVars[$templateVar] = $assocValue;
}
return strtr($template, $templateVars);
};
}
/**
* @param int $decimals
* @param string $decimalSeperator
* @param string $thousandsSeperator
*
* @return Closure
*/
public static function number($decimals = 2, $decimalSeperator = ',', $thousandsSeperator = '.')
{
return static function ($value) use ($decimals, $decimalSeperator, $thousandsSeperator) {
return number_format($value, $decimals, $decimalSeperator, $thousandsSeperator);
};
}
/**
* @param int $decimals
* @param string $decimalSeperator
* @param string $thousandsSeperator
*
* @return Closure
*/
public static function bytes($decimals = 1, $decimalSeperator = ',', $thousandsSeperator = '.')
{
return static function ($bytes) use ($decimals, $decimalSeperator, $thousandsSeperator) {
$bytes = (float)$bytes;
$base = log($bytes, 1024);
$suffixes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
$suffixIndex = (int)floor($base);
$suffix = $suffixes[$suffixIndex];
$number = pow(1024, $base - floor($base));
return number_format($number, $decimals, $decimalSeperator, $thousandsSeperator) . ' ' . $suffix;
};
}
/**
* @param string $dateFormat https://www.php.net/manual/de/function.date.php
*
* @return Closure
*/
public static function date($dateFormat)
{
return static function ($dateString) use ($dateFormat) {
try {
$date = new DateTime($dateString);
return $date->format($dateFormat);
} catch (Exception $exception) {
return $exception->getMessage();
}
};
}
/**
* @return Closure
*/
public static function htmlEscape()
{
return static function ($value) {
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
};
}
/**
* @todo Fixen
*
* @return Closure
*/
public static function dump()
{
return static function ($value, $row) {
$data = [
'value' => $value,
'row' => $row,
];
return sprintf(
'<pre class="dump">%s</pre>',
htmlspecialchars(var_export($data, true))
);
};
}
}
@@ -0,0 +1,219 @@
<?php
namespace Xentral\Widgets\DataTable;
use Xentral\Widgets\DataTable\Exception\InvalidArgumentException;
use Xentral\Widgets\DataTable\Type\DataTableTypeInterface;
final class DataTableBuildConfig
{
/** @var string $tableName */
private $tableName;
/** @var string $tableClass */
private $tableClass;
/** @var string $ajaxUrl */
private $ajaxUrl;
/** @var string $ajaxMethod */
private $ajaxMethod;
/** @var array $ajaxParams Additional AJAX parameter */
private $ajaxParams;
/** @var bool $autoInit */
private $autoInit;
/**
* Available DataTable classes: display, compact, hover, order-column, row-border, cell-border, stripe, nowrap
*
* display = Short-hand for stripe, hover, row-border and order-column.
*
* @see https://datatables.net/manual/styling/classes#Table-classes
*
* @var array $cssClasses
*/
private $cssClasses = [];
/**
* @param string $tableName Unique table name; Will be used as id-attribute on <table> element
* @param string $tableClass FQCN of DataTable class that implements DataTableTypeInterface
* @param string $ajaxUrl
* @param bool $autoInit
*/
public function __construct($tableName, $tableClass, $ajaxUrl, $autoInit = true)
{
if (!class_exists($tableClass, true)) {
throw new InvalidArgumentException(sprintf('DataTable class "%s" not found', $tableClass));
}
$interfaces = class_implements($tableClass, true);
if (!in_array(DataTableTypeInterface::class, $interfaces, true)) {
throw new InvalidArgumentException('DataTable class does not implement %s', DataTableTypeInterface::class);
}
$tableNameCleaned = preg_replace('/[^a-z0-9_-]+/', '', $tableName);
if ($tableNameCleaned !== $tableName) {
throw new InvalidArgumentException(sprintf(
'Table name "%s" contains illegal characters. ' .
'Valid characters are: a-z, 0-9, hyphens and underscores.',
$tableName
));
}
$this->tableName = $tableName;
$this->tableClass = $tableClass;
$this->ajaxUrl = $ajaxUrl;
$this->ajaxMethod = 'GET';
$this->ajaxParams = ['tablename' => $tableName];
$this->autoInit = $autoInit;
}
/**
* @return string
*/
public function getTableName()
{
return $this->tableName;
}
/**
* @return string
*/
public function getTableClass()
{
return $this->tableClass;
}
/**
* @return string
*/
public function getAjaxUrl()
{
return $this->ajaxUrl;
}
/**
* @return string
*/
public function getAjaxMethod()
{
return $this->ajaxMethod;
}
/**
* @param string $method [GET|POST]
*
* @throws InvalidArgumentException
*
* @return void
*/
public function setAjaxMethod($method)
{
$method = strtoupper($method);
if (!in_array($method, ['GET', 'POST'], true)) {
throw new InvalidArgumentException('AJAX method "%s" is invalid.', $method);
}
$this->ajaxMethod = $method;
}
/**
* @return array
*/
public function getAjaxParams()
{
return $this->ajaxParams;
}
/**
* @param string $param
* @param mixed $value
*
* @throws InvalidArgumentException
*/
public function setAjaxParam($param, $value)
{
$cleanedName = (string)preg_replace('#[^A-Za-z0-9_-]#', '', trim($param));
if ($cleanedName !== $param) {
throw new InvalidArgumentException(sprintf(
'AJAX parameter name "%s" contains illegal characters. ' .
'Valid characters are: a-z, 0-9, hyphens and underscores.',
$param
));
}
if ($param === 'tablename') {
throw new InvalidArgumentException('AJAX parameter "tablename" is reserved.');
}
$this->ajaxParams[$param] = $value;
}
/**
* @return bool
*/
public function isAutoInit()
{
return $this->autoInit;
}
/**
* @return string
*/
public function getCssClassesString()
{
return implode(' ', $this->getCssClasses());
}
/**
* @return array
*/
public function getCssClasses()
{
return $this->cssClasses;
}
/**
* @param string $className
*
* @return bool
*/
public function hasCssClass($className)
{
return in_array($className, $this->cssClasses, true);
}
/**
* @param string $className
*
* @return void
*/
public function addCssClass($className)
{
$cleanedName = (string)preg_replace('#[^a-z0-9_-]#', '', trim($className));
if ($cleanedName !== $className) {
throw new InvalidArgumentException(sprintf(
'CSS class name "%s" contains illegal characters. ' .
'Valid characters are: a-z, 0-9, hyphens and underscores.',
$className
));
}
$this->cssClasses[] = $cleanedName;
$this->cssClasses = array_unique($this->cssClasses);
}
/**
* @param string $className
*
* @return void
*/
public function removeCssClass($className)
{
$classKey = array_search($className, $this->cssClasses, true);
if ($classKey !== false) {
unset($this->cssClasses[$classKey]);
$this->cssClasses = array_values($this->cssClasses);
}
}
}
@@ -0,0 +1,77 @@
<?php
namespace Xentral\Widgets\DataTable;
use Xentral\Core\DependencyInjection\ContainerInterface;
use Xentral\Widgets\DataTable\Request\DataTableRequest;
use Xentral\Widgets\DataTable\Service\DataTableBuilder;
use Xentral\Widgets\DataTable\Service\DataTableFetcher;
use Xentral\Widgets\DataTable\Service\DataTableRenderer;
use Xentral\Widgets\DataTable\Service\DataTableRequestHandler;
use Xentral\Widgets\DataTable\Service\DataTableService;
final class DataTableFactory
{
/** @var ContainerInterface */
private $container;
/**
* @param ContainerInterface $container
*/
public function __construct(ContainerInterface $container)
{
$this->container = $container;
}
/**
* @return DataTableRequestHandler
*/
public function createDataTableRequestHandler()
{
return new DataTableRequestHandler($this->createDataTableService(), $this->createDataTableRequest());
}
/**
* @return DataTableService
*/
public function createDataTableService()
{
return new DataTableService(
$this->createDataTableBuilder(),
$this->createDataTableRenderer(),
$this->createDataTableFetcher()
);
}
/**
* @return DataTableBuilder
*/
private function createDataTableBuilder()
{
return new DataTableBuilder($this->container->get('Database'));
}
/**
* @return DataTableRenderer
*/
private function createDataTableRenderer()
{
return new DataTableRenderer();
}
/**
* @return DataTableFetcher
*/
private function createDataTableFetcher()
{
return new DataTableFetcher($this->container->get('Database'), $this->createDataTableRequest());
}
/**
* @return DataTableRequest
*/
private function createDataTableRequest()
{
return DataTableRequest::fromRequest($this->container->get('Request'));
}
}
@@ -0,0 +1,48 @@
<?php
namespace Xentral\Widgets\DataTable;
use Closure;
use Xentral\Components\Database\SqlQuery\SelectQuery;
use Xentral\Widgets\DataTable\Column\ColumnCollection;
use Xentral\Widgets\DataTable\Options\DataTableOptions;
use Xentral\Widgets\DataTable\Feature\FeatureCollection;
use Xentral\Widgets\DataTable\Filter\FilterCollection;
interface DataTableInterface
{
/**
* @return DataTableBuildConfig
*/
public function getConfig();
/**
* @return DataTableOptions
*/
public function getOptions();
/**
* @return ColumnCollection
*/
public function getColumns();
/**
* @return FeatureCollection
*/
public function getFeatures();
/**
* @return SelectQuery
*/
public function getBaseQuery();
/**
* @return Closure|null
*/
public function getCustomSearch();
/**
* @return FilterCollection
*/
public function getFilters();
}
@@ -0,0 +1,9 @@
<?php
namespace Xentral\Widgets\DataTable\Exception;
use RuntimeException;
class BuildFailedException extends RuntimeException implements DataTableExceptionInterface
{
}
@@ -0,0 +1,12 @@
<?php
namespace Xentral\Widgets\DataTable\Exception;
use RuntimeException;
/**
* Wenn bereits eine Column mit diesem Name existiert; Namen wüssen einmalig sein
*/
class ColumnNameAssignedException extends RuntimeException implements DataTableExceptionInterface
{
}
@@ -0,0 +1,9 @@
<?php
namespace Xentral\Widgets\DataTable\Exception;
use RuntimeException;
class ColumnNotFoundException extends RuntimeException implements DataTableExceptionInterface
{
}
@@ -0,0 +1,18 @@
<?php
namespace Xentral\Widgets\DataTable\Exception;
use RuntimeException;
class DataTableException extends RuntimeException implements DataTableExceptionInterface
{
/**
* @param string $columnName
*
* @return DataTableException
*/
/*public static function columnNotFound($columnName)
{
return new self(sprintf('Column "%s" not found.', $columnName));
}*/
}
@@ -0,0 +1,9 @@
<?php
namespace Xentral\Widgets\DataTable\Exception;
use Xentral\Core\Exception\WidgetExceptionInterface;
interface DataTableExceptionInterface extends WidgetExceptionInterface
{
}
@@ -0,0 +1,7 @@
<?php
namespace Xentral\Widgets\DataTable\Exception;
class FeatureExistsException extends \RuntimeException implements DataTableExceptionInterface
{
}
@@ -0,0 +1,9 @@
<?php
namespace Xentral\Widgets\DataTable\Exception;
use LogicException;
class FeatureIncompatibleException extends LogicException implements DataTableExceptionInterface
{
}
@@ -0,0 +1,7 @@
<?php
namespace Xentral\Widgets\DataTable\Exception;
class FeatureNotFoundException extends \InvalidArgumentException implements DataTableExceptionInterface
{
}
@@ -0,0 +1,9 @@
<?php
namespace Xentral\Widgets\DataTable\Exception;
use LogicException;
class FeatureNotImplementedException extends LogicException implements DataTableExceptionInterface
{
}
@@ -0,0 +1,9 @@
<?php
namespace Xentral\Widgets\DataTable\Exception;
use InvalidArgumentException as SplInvalidArgumentException;
class InvalidArgumentException extends SplInvalidArgumentException implements DataTableExceptionInterface
{
}
@@ -0,0 +1,42 @@
<?php
namespace Xentral\Widgets\DataTable\Feature;
use Xentral\Widgets\DataTable\Options\DataTableOptions;
use Xentral\Widgets\DataTable\DataTableInterface;
use Xentral\Widgets\DataTable\Exception\FeatureNotImplementedException;
/**
* @todo
*
* @example https://datatables.net/reference/api/columns().footer()#Example
*/
final class ColumnAggregateFeature implements DataTableFeatureInterface
{
/**
* @throws FeatureNotImplementedException
*/
public function __construct()
{
throw new FeatureNotImplementedException('Feature is not implemented yet.');
}
/**
* @param DataTableInterface $table
*
* @return void
*/
public function modifyTable(DataTableInterface $table)
{
$this->modifyOptions($table->getOptions());
}
/**
* @param DataTableOptions $options
*
* @return void
*/
private function modifyOptions(DataTableOptions $options)
{
}
}
@@ -0,0 +1,131 @@
<?php
namespace Xentral\Widgets\DataTable\Feature;
use Xentral\Widgets\DataTable\Column\Column;
use Xentral\Widgets\DataTable\DataTableInterface;
/**
* @todo Fertigstellen
*/
final class ColumnFilterFeature implements DataTableFeatureInterface
{
/** @var string TYPE_NONE Filter deactivated */
const TYPE_NONE = 'none'; // Filter deactivated
/** @var string TYPE_TEXT Default filter */
const TYPE_TEXT = 'text'; // Default
/** @var string TYPE_TEXT_MULTI @todo Mehrere Wörter mit ODER suchen */
const TYPE_TEXT_MULTI = 'text_multi';
/** @var string TYPE_SELECT @todo Dropdown */
const TYPE_SELECT = 'select';
/** @var string TYPE_NUMBER */
const TYPE_NUMBER = 'number';
/** @var string TYPE_NUMBER_RANGE */
const TYPE_NUMBER_RANGE = 'number_range';
/** @var string TYPE_DATE @todo */
const TYPE_DATE = 'date';
/** @var string TYPE_DATE_RANGE @todo */
const TYPE_DATE_RANGE = 'date_range';
/** @var array $columnSettings */
private $columnSettings = [];
/**
* @param DataTableInterface $table
*
* @return void
*/
public function modifyTable(DataTableInterface $table)
{
$this->modifyOptions($table);
}
/**
* @param string $columnName
*
* @return void
*/
public function addNumberRangeFilter($columnName)
{
$this->columnSettings[$columnName] = [
'name' => $columnName,
'type' => self::TYPE_NUMBER_RANGE,
];
}
/**
* @todo
*
* @param string $columnName
*
* @return void
*/
public function addMultiWordFilter($columnName)
{
$this->columnSettings[$columnName] = [
'name' => $columnName,
'type' => self::TYPE_TEXT,
'multi_word' => true,
];
}
/**
* @todo
*
* @param string $columnName
*
* @return void
*/
// public function addDropdownFilter($columnName)
// {
// $this->columnSettings[$columnName] = [
// 'name' => $columnName,
// 'type' => self::TYPE_SELECT,
// ];
// }
/**
* @param DataTableInterface $table
*
* @return void
*/
private function modifyOptions(DataTableInterface $table)
{
$result = [];
/** @var Column $column */
foreach ($table->getColumns() as $index => $column) {
$columnName = $column->getName();
if (isset($this->columnSettings[$columnName])) {
$result[$index] = $this->columnSettings[$columnName];
} else {
// Default
$defaultType = $column->isSearchable() ? self::TYPE_TEXT : self::TYPE_NONE;
$result[$index] = [
'name' => $column->getName(),
'type' => $defaultType,
];
}
if ($columnName === 'id' || $columnName === 'menu') {
$result[$index] = [
'name' => $column->getName(),
'type' => self::TYPE_NONE, // Column filtering inactive on menu and id column
];
}
if (strpos($columnName, '_') === 0) {
$result[$index] = [
'name' => $column->getName(),
'type' => self::TYPE_NONE, // @todo Wird benötigt?
];
}
}
$table->getOptions()->setOption('columnFilter', $result);
}
}
@@ -0,0 +1,44 @@
<?php
namespace Xentral\Widgets\DataTable\Feature;
use Xentral\Widgets\DataTable\Options\DataTableOptions;
use Xentral\Widgets\DataTable\DataTableInterface;
use Xentral\Widgets\DataTable\Exception\FeatureIncompatibleException;
/**
* @deprecated Nicht verwenden; Momentan inkompatibel mit ColumnFilter! Filter-Eingabefelder werden falsch zugeordnet.
*
* @see https://datatables.net/extensions/colreorder/
*/
final class ColumnReorderFeature implements DataTableFeatureInterface
{
/**
* @throws FeatureIncompatibleException
*/
public function __construct()
{
throw new FeatureIncompatibleException('DataTable feature "ColumnReorder" is incompatible.');
}
/**
* @param DataTableInterface $table
*
* @return void
*/
public function modifyTable(DataTableInterface $table)
{
$this->modifyOptions($table->getOptions());
}
/**
* @param DataTableOptions $options
*
* @return void
*/
private function modifyOptions(DataTableOptions $options)
{
/** @see https://datatables.net/reference/option/colReorder */
$options->setOption('colReorder', ['enable' => true, 'realtime' => false]);
}
}
@@ -0,0 +1,18 @@
<?php
namespace Xentral\Widgets\DataTable\Feature;
use Xentral\Widgets\DataTable\DataTableInterface;
use Xentral\Widgets\DataTable\Exception\DataTableExceptionInterface;
interface DataTableFeatureInterface
{
/**
* @param DataTableInterface $table
*
* @throws DataTableExceptionInterface
*
* @return void
*/
public function modifyTable(DataTableInterface $table);
}
@@ -0,0 +1,57 @@
<?php
namespace Xentral\Widgets\DataTable\Feature;
use Xentral\Widgets\DataTable\DataTableInterface;
final class DebugFeature implements DataTableFeatureInterface
{
/** @var bool $enabled */
private $enabled;
/**
* @param bool $enabled
*/
public function __construct($enabled = true)
{
$this->enabled = (bool)$enabled;
}
/**
* @param DataTableInterface $table
*
* @return void
*/
public function modifyTable(DataTableInterface $table)
{
if ($this->enabled === true) {
$table->getConfig()->addCssClass('datatable-debug');
} else {
$table->getConfig()->removeCssClass('datatable-debug');
}
}
/**
* @return bool
*/
public function isEnabled()
{
return $this->enabled;
}
/**
* @return void
*/
public function enable()
{
$this->enabled = true;
}
/**
* @return void
*/
public function disable()
{
$this->enabled = false;
}
}
@@ -0,0 +1,174 @@
<?php
namespace Xentral\Widgets\DataTable\Feature;
use ArrayIterator;
use IteratorAggregate;
use Traversable;
use Xentral\Widgets\DataTable\Exception\FeatureExistsException;
use Xentral\Widgets\DataTable\Exception\FeatureNotFoundException;
use Xentral\Widgets\DataTable\Exception\InvalidArgumentException;
class FeatureCollection implements IteratorAggregate
{
/** @var array $features */
protected $features = [];
/**
* @param array|DataTableFeatureInterface[] $features
*/
public function __construct(array $features = [])
{
foreach ($features as $feature) {
$this->add($feature);
}
}
/**
* @param string $className Full-qualified class name
*
* @return bool
*/
public function has($className)
{
$this->ensureClassNameParameter($className, __METHOD__);
foreach ($this->features as $feature) {
if (get_class($feature) === $className) {
return true;
}
}
return false;
}
/**
* @param string $className Full-qualified class name
*
* @throws FeatureNotFoundException
*
* @return DataTableFeatureInterface
*/
public function get($className)
{
$this->ensureClassNameParameter($className, __METHOD__);
foreach ($this->features as $feature) {
if (get_class($feature) === $className) {
return $feature;
}
}
throw new FeatureNotFoundException(sprintf('Feature class "%s" not found.', $className));
}
/**
* @return array|DataTableFeatureInterface[]
*/
public function all()
{
return $this->features;
}
/**
* Adds a new feature
*
* @param DataTableFeatureInterface $feature
*
* @throws FeatureExistsException If feature with same type already exists
*
* @return void
*/
public function add(DataTableFeatureInterface $feature)
{
if ($this->has(get_class($feature))) {
throw new FeatureExistsException(sprintf('Feature class "%s" already exists', get_class($feature)));
}
$this->features[] = $feature;
}
/**
* Sets a feature; If feature with same type exists, it will be overwritten.
*
* @param DataTableFeatureInterface $feature
*
* @return void
*/
public function set(DataTableFeatureInterface $feature)
{
$this->remove(get_class($feature));
$this->features[] = $feature;
}
/**
* @param string|object $className Full-qualified class name
*
* @return bool
*/
public function remove($className)
{
$this->ensureClassNameParameter($className, __METHOD__);
foreach ($this->features as $index => $feature) {
if (get_class($feature) === $className) {
unset($this->features[$index]);
return true;
}
}
return false;
}
/**
* @return void
*/
public function removeAll()
{
$this->features = [];
}
/**
* @return ArrayIterator|Traversable
*/
public function getIterator()
{
return new ArrayIterator($this->features);
}
/**
* Deep copy object
*
* @return void
*/
public function __clone()
{
foreach ($this->features as $index => $column) {
$this->features[$index] = clone $column;
}
}
/**
* @param mixed $className
* @param string $callerName
*
* @throws InvalidArgumentException
*
* @return void
*/
protected function ensureClassNameParameter($className, $callerName)
{
if (!is_string($className)) {
throw new InvalidArgumentException(sprintf(
'Parameter "className" in method "%s" has to be a class name.', $callerName
));
}
if (!class_exists($className, true)) {
throw new InvalidArgumentException(sprintf(
'"%s" is not a valid class.', $className
));
}
}
}
@@ -0,0 +1,42 @@
<?php
namespace Xentral\Widgets\DataTable\Feature;
use Xentral\Widgets\DataTable\DataTableInterface;
use Xentral\Widgets\DataTable\Options\DataTableOptions;
use Xentral\Widgets\DataTable\Exception\FeatureIncompatibleException;
/**
* @see https://datatables.net/extensions/fixedheader/
*/
final class FixedHeaderFeature implements DataTableFeatureInterface
{
/**
* @throws FeatureIncompatibleException
*/
public function __construct()
{
throw new FeatureIncompatibleException('Feature "FixedHeaderFeature" does not work currently.');
}
/**
* @param DataTableInterface $table
*
* @return void
*/
public function modifyTable(DataTableInterface $table)
{
$this->modifyOptions($table->getOptions());
}
/**
* @param DataTableOptions $options
*
* @return void
*/
private function modifyOptions(DataTableOptions $options)
{
/** @see https://datatables.net/reference/option/fixedHeader */
$options->setOption('fixedHeader', true);
}
}
@@ -0,0 +1,103 @@
<?php
namespace Xentral\Widgets\DataTable\Feature;
use Xentral\Widgets\DataTable\Column\Column;
use Xentral\Widgets\DataTable\Column\ColumnCollection;
use Xentral\Widgets\DataTable\DataTableInterface;
use Xentral\Widgets\DataTable\Options\DataTableOptions;
/**
* @see https://datatables.net/extensions/responsive/
*/
final class ResponsiveFeature implements DataTableFeatureInterface
{
/** @var int PRIO_HIGHEST */
const PRIO_HIGHEST = 1;
/** @var int PRIO_HIGHER */
const PRIO_HIGHER = 10;
/** @var int PRIO_NORMAL */
const PRIO_NORMAL = 100;
/** @var int PRIO_LOWER */
const PRIO_LOWER = 1000;
/** @var int PRIO_LOWEST */
const PRIO_LOWEST = 10000;
/** @var array $responsiveProperty */
private $responsiveProperty = [
'details' => false,
];
/** @var array $columnPriorities */
private $columnPriorities = [];
/** @var int $defaultPriority */
private $defaultPriority = self::PRIO_NORMAL;
/**
* @param DataTableInterface $table
*
* @return void
*/
public function modifyTable(DataTableInterface $table)
{
$this->modifyOptions($table->getOptions());
$this->modifyColumns($table->getColumns());
}
/**
* @param string $columnName
* @param int $priority
*
* @return void
*/
public function setPriority($columnName, $priority)
{
$this->columnPriorities[$columnName] = (int)$priority;
}
/**
* @param int $priority
*
* @return void
*/
public function setDefaultPriority($priority)
{
$this->defaultPriority = (int)$priority;
}
/**
* @param DataTableOptions $options
*
* @return void
*/
private function modifyOptions(DataTableOptions $options)
{
$options->setOption('responsive', $this->responsiveProperty);
$options->removeOption('scrollX');
}
/**
* @param ColumnCollection $columns
*
* @return void
*/
private function modifyColumns(ColumnCollection $columns)
{
/** @var Column $column */
foreach ($columns as $column) {
$name = $column->getName();
if (isset($this->columnPriorities[$name])) {
$column->set('responsivePriority', $this->columnPriorities[$name]);
} else {
if (!$column->has('responsivePriority')) {
$column->set('responsivePriority', $this->defaultPriority);
}
}
}
}
}
@@ -0,0 +1,119 @@
<?php
namespace Xentral\Widgets\DataTable\Feature;
use Closure;
use Xentral\Widgets\DataTable\DataTableInterface;
final class RowClassesFeature implements DataTableFeatureInterface
{
/** @var array $colors */
private static $availableColors = [
'lightgray',
'lightgreen',
'lightteal',
'lightcyan',
'lightblue',
'lightindigo',
'lightviolet',
'lightfuchsia',
'lightpink',
'lightred',
'lightorange',
'lightyellow',
'lightlime',
];
/** @var array|string[] $classes */
private $classes;
/** @var array|Closure[] $customFormatter */
private $customFormatter = [];
/**
* @param array|string[] $classes
* @param array|Closure $customFormatter
*/
public function __construct(array $classes = [], array $customFormatter = [])
{
foreach ($classes as $class) {
$this->addClass($class);
}
foreach ($customFormatter as $formatter) {
$this->addCustomFormatter($formatter);
}
}
/**
* @param string $className
*
* @return void
*/
public function addClass($className)
{
$this->classes[] = trim($className);
}
/**
* @return array|string[]
*/
public function getClasses()
{
return $this->classes;
}
/**
* @return string
*/
public function getClassesString()
{
return implode(' ', $this->classes);
}
/**
* @return bool
*/
public function hasCustomFormatter()
{
return !empty($this->customFormatter);
}
/**
* @return array|Closure[]
*/
public function getCustomFormatter()
{
return $this->customFormatter;
}
/**
* @param Closure $closure
*
* @return void
*/
public function addCustomFormatter(Closure $closure)
{
$this->customFormatter[] = $closure;
}
/**
* @param DataTableInterface $table
*
* @return void
*/
public function modifyTable(DataTableInterface $table)
{
// @todo Logik steckt momentan in DataTableRenderer; muss aber hier rein
}
/**
* @return string
*/
public function getRandomColor()
{
$count = count(self::$availableColors);
$index = mt_rand() % $count;
return self::$availableColors[$index];
}
}
@@ -0,0 +1,67 @@
<?php
namespace Xentral\Widgets\DataTable\Feature;
use Xentral\Widgets\DataTable\Column\Column;
use Xentral\Widgets\DataTable\Column\ColumnFormatter;
use Xentral\Widgets\DataTable\DataTableInterface;
use Xentral\Widgets\DataTable\Exception\DataTableExceptionInterface;
use Xentral\Widgets\DataTable\Exception\InvalidArgumentException;
final class RowDetailsFeature implements DataTableFeatureInterface
{
/** @var string $ajaxUrl */
private $ajaxUrl;
/** @var string $ajaxMethod */
private $ajaxMethod;
/** @var array $ajaxParams @todo Additional AJAX parameter */
private $ajaxParams = [];
/**
* Der Wert aus der id-Spalte wird als POST-Parameter `id` übergeben
*
* @param string $ajaxUrl `./index.php?module=foo&action=bar`
* @param string $ajaxMethod [GET|POST]
* @param callable|null $customFormatter @todo
*/
public function __construct($ajaxUrl, $ajaxMethod = 'POST', $customFormatter = null)
{
$ajaxMethod = strtoupper($ajaxMethod);
if (!in_array($ajaxMethod, ['GET', 'POST'])) {
throw new InvalidArgumentException(sprintf('Invalid method "%s".', $ajaxMethod));
}
$this->ajaxUrl = $ajaxUrl;
$this->ajaxMethod = $ajaxMethod;
}
/**
* @param DataTableInterface $table
*
* @throws DataTableExceptionInterface
*
* @return void
*/
public function modifyTable(DataTableInterface $table)
{
$table->getOptions()->setOption('rowDetails', [
'ajax' => [
'url' => $this->ajaxUrl,
'method' => $this->ajaxMethod,
'data' => $this->ajaxParams,
],
]);
// Detail-Spalte erzeugen
$newCol = Column::fixed('details', '', 'center', '20px');
$newCol->setFormatter(ColumnFormatter::template('<span class="details" data-id="{ID}"></span>'));
$newCol->addCssClass('dt-details');
// Detail-Spalte vor erste Spalte einfügen
/** @var Column $firstCol */
$firstCol = $table->getColumns()->getByIndex(0);
$table->getColumns()->addBefore($newCol, $firstCol->getName());
}
}
@@ -0,0 +1,87 @@
<?php
namespace Xentral\Widgets\DataTable\Feature;
use Xentral\Widgets\DataTable\DataTableInterface;
use Xentral\Widgets\DataTable\Exception\ColumnNotFoundException;
use Xentral\Widgets\DataTable\Exception\InvalidArgumentException;
/**
* @see https://datatables.net/extensions/rowgroup/
*/
final class RowGroupFeature implements DataTableFeatureInterface
{
/** @var array $groupColumns */
private $groupColumns;
/** @var bool $enabled */
private $enabled;
/**
* @param array $columnNames
*/
public function __construct(array $columnNames)
{
if (count($columnNames) === 0) {
throw new InvalidArgumentException('Parameter "columnNames" is can not be empty.');
}
$this->groupColumns = $columnNames;
$this->enabled = true;
}
/**
* @param DataTableInterface $table
*
* @throws ColumnNotFoundException
*
* @return void
*/
public function modifyTable(DataTableInterface $table)
{
foreach ($this->groupColumns as $columnName) {
if (!$table->getColumns()->has($columnName)) {
throw new ColumnNotFoundException(sprintf(
'RowGroupFeature failed. Column "%s" not found.',
$columnName
));
}
}
if ($this->enabled === true) {
$table->getOptions()->setOption('rowGroup', ['dataSrc' => $this->groupColumns]);
}
if ($this->enabled === false) {
$table->getOptions()->setOption('rowGroup', false);
}
}
/**
* @param array $columnNames
*
* @return void
*/
public function groupBy(array $columnNames)
{
if (count($columnNames) === 0) {
throw new InvalidArgumentException('Parameter "columnNames" is can not be empty.');
}
$this->groupColumns = $columnNames;
}
/**
* @return void
*/
public function enable()
{
$this->enabled = true;
}
/**
* @return void
*/
public function disable()
{
$this->enabled = false;
}
}
@@ -0,0 +1,81 @@
<?php
namespace Xentral\Widgets\DataTable\Feature;
use Xentral\Widgets\DataTable\DataTableInterface;
final class StateSaveFeature implements DataTableFeatureInterface
{
/** @var bool $enabled */
private $enabled;
/** @var int $duration In seconds (0 = Forever) */
private $duration;
/**
* @param bool $enabled
* @param int $duration
*/
public function __construct($enabled = true, $duration = 0)
{
$this->enabled = (bool)$enabled;
$this->duration = (int)$duration;
}
/**
* @param DataTableInterface $table
*
* @return void
*/
public function modifyTable(DataTableInterface $table)
{
// $options = $table->getOptions()->toArray();
// $options['columns'] = $table->getColumns()->toArray();
// $table->getOptions()->setOption('revision', md5(json_encode($options)));
$table->getOptions()->setOption('stateSave', $this->enabled);
$table->getOptions()->setOption('stateDuration', $this->duration);
}
/**
* @return void
*/
public function enable()
{
$this->enabled = true;
}
/**
* @return void
*/
public function disable()
{
$this->enabled = false;
}
/**
* @return bool
*/
public function isEnabled()
{
return $this->enabled;
}
/**
* @return int
*/
public function getDuration()
{
return $this->duration;
}
/**
* @param int $duration
*
* @return void
*/
public function setDuration($duration)
{
$this->duration = (int)$duration;
}
}
@@ -0,0 +1,264 @@
<?php
namespace Xentral\Widgets\DataTable\Feature;
use Xentral\Widgets\DataTable\DataTableInterface;
final class TableControlFeature implements DataTableFeatureInterface
{
/** @var array $buttonConfigCopy */
private static $buttonConfigCopy = [
'extend' => 'copy',
'text' => 'Zwischenablage',
];
/** @var array $buttonConfigCsv */
private static $buttonConfigCsv = [
'extend' => 'collection',
'text' => 'CSV',
'collectionTitle' => 'CSV-Export',
'autoClose' => true,
'buttons' => [
[
'text' => 'Alle Seiten',
'action' => 'export-csv-all',
],
[
'text' => 'Aktuelle Seite',
'action' => 'export-csv-page',
],
],
];
/** @var array $buttonConfigExcel */
private static $buttonConfigExcel = [
'extend' => 'excel',
'text' => 'Excel',
];
/** @var array $buttonConfigPdf */
private static $buttonConfigPdf = [
'extend' => 'pdf',
'text' => 'PDF',
'orientation' => 'landscape',
'pageSize' => 'A4',
];
/** @var array $buttonConfigPrint */
private static $buttonConfigPrint = [
'extend' => 'print',
'text' => 'Drucken',
];
/** @var bool $info */
private $info = true;
/** @var bool $paging */
private $paging = true;
/** @var bool $searching */
private $searching = true;
/** @var bool $lengthChange */
private $lengthChange = true;
/** @var int|null $pageLength */
private $pageLength;
/** @var bool $processing */
private $processing = true;
/** @var bool $sorting */
private $sorting = true;
/** @var array $buttons */
private $buttons = [];
/**
*/
public function __construct()
{
$this->setFullMode();
}
/**
* @param DataTableInterface $table
*
* @return void
*/
public function modifyTable(DataTableInterface $table)
{
$table->getOptions()->setOption('info', $this->info);
$table->getOptions()->setOption('paging', $this->paging);
$table->getOptions()->setOption('buttons', $this->buttons);
$table->getOptions()->setOption('searching', $this->searching);
$table->getOptions()->setOption('lengthChange', $this->lengthChange);
$table->getOptions()->setOption('processing', $this->processing);
$table->getOptions()->setOption('ordering', $this->sorting);
if ($this->pageLength !== null && $this->pageLength > 0) {
$table->getOptions()->setOption('pageLength', $this->pageLength);
$table->getOptions()->setOption('lengthChange', false);
}
}
/**
* @return void
*/
public function setFullMode()
{
$this->showInfo();
$this->showButtons();
$this->showLengthChange();
$this->enableSearching();
$this->enableSorting();
$this->enablePaging();
}
/**
* @return void
*/
public function setMinimalMode()
{
$this->showInfo();
$this->enablePaging();
$this->enableSorting();
$this->hideButtons();
$this->hideLengthChange();
$this->disableSearching();
}
/**
* @return void
*/
public function showInfo()
{
$this->info = true;
}
/**
* @return void
*/
public function hideInfo()
{
$this->info = false;
}
/**
* @return void
*/
public function showButtons()
{
$this->buttons = [
'buttons' => [
self::$buttonConfigCopy,
self::$buttonConfigCsv,
self::$buttonConfigExcel,
self::$buttonConfigPdf,
self::$buttonConfigPrint,
],
];
}
/**
* @return void
*/
public function hideButtons()
{
$this->buttons = [];
}
/**
* @return void
*/
public function showLengthChange()
{
$this->lengthChange = true;
}
/**
* @return void
*/
public function hideLengthChange()
{
$this->lengthChange = false;
}
/**
* @param int $rowsPerPage
*
* @return void
*/
public function setPageLength($rowsPerPage)
{
$this->pageLength = (int)$rowsPerPage;
$this->hideLengthChange();
}
/**
* @return void
*/
public function showProcessingIndicator()
{
$this->processing = true;
}
/**
* @return void
*/
public function hideProcessingIndicator()
{
$this->processing = false;
}
/**
* @return void
*/
public function enableSearching()
{
// @todo ColumnFilter aktivieren
$this->searching = true;
}
/**
* @return void
*/
public function disableSearching()
{
// @todo ColumnFilter deaktivieren
$this->searching = false;
}
/**
* @return void
*/
public function enableSorting()
{
$this->sorting = true;
}
/**
* @return void
*/
public function disableSorting()
{
$this->sorting = false;
}
/**
* @return void
*/
public function enablePaging()
{
$this->paging = true;
}
/**
* @return void
*/
public function disablePaging()
{
$this->paging = false;
$this->lengthChange = false;
}
}
@@ -0,0 +1,164 @@
<?php
namespace Xentral\Widgets\DataTable\Feature;
use Xentral\Widgets\DataTable\DataTableInterface;
final class TableStylingFeature implements DataTableFeatureInterface
{
/** @var array $cssClasses */
private $cssClasses = [];
/**
* @param bool $compact
* @param bool $disableLineWrapping
*/
public function __construct($compact = false, $disableLineWrapping = false)
{
$this->setDefaultStyle();
if ($compact === true) {
$this->setCompactStyle();
}
if ($disableLineWrapping === true) {
$this->disableLineWrapping();
} else {
$this->enableLineWrapping();
}
}
/**
* @param DataTableInterface $table
*
* @return void
*/
public function modifyTable(DataTableInterface $table)
{
foreach ($this->cssClasses as $cssClass) {
$table->getConfig()->addCssClass($cssClass);
}
}
/**
* display: Short-hand for stripe, hover, row-border and order-column.
*
* @return void
*/
public function setDefaultStyle()
{
$this->removeCssClass('display');
$this->removeCssClass('compact');
$this->removeCssClass('order-column');
$this->enableHover();
$this->disableRowBorder();
$this->disableStripes();
}
/**
* @return void
*/
public function setCompactStyle()
{
$this->addCssClass('compact');
}
/**
* @return void
*/
public function enableLineWrapping()
{
$this->removeCssClass('nowrap');
}
/**
* @return void
*/
public function disableLineWrapping()
{
$this->addCssClass('nowrap');
}
/**
* @return void
*/
public function enableHover()
{
$this->addCssClass('hover');
}
/**
* @return void
*/
public function disableHover()
{
$this->removeCssClass('hover');
}
/**
* @return void
*/
public function enableStripes()
{
$this->addCssClass('stripe');
}
/**
* @return void
*/
public function disableStripes()
{
$this->removeCssClass('stripe');
}
/**
* @return void
*/
public function enableRowBorder()
{
$this->addCssClass('row-border');
$this->removeCssClass('cell-border');
}
/**
* @return void
*/
public function disableRowBorder()
{
$this->removeCssClass('row-border');
}
/**
* @param string $className
*
* @return bool
*/
private function hasCssClass($className)
{
return in_array($className, $this->cssClasses, true);
}
/**
* @param string $className
*
* @return void
*/
private function addCssClass($className)
{
$this->cssClasses[] = trim($className);
$this->cssClasses = array_unique($this->cssClasses);
}
/**
* @param string $className
*
* @return void
*/
private function removeCssClass($className)
{
$classKey = array_search($className, $this->cssClasses, true);
if ($classKey !== false) {
unset($this->cssClasses[$classKey]);
$this->cssClasses = array_values($this->cssClasses);
}
}
}
@@ -0,0 +1,25 @@
<?php
namespace Xentral\Widgets\DataTable\Filter;
use Xentral\Widgets\DataTable\DataTableInterface;
use Xentral\Widgets\DataTable\Request\DataTableRequest;
abstract class AbstractFilter implements FilterInterface
{
/** @var string $type */
protected $type;
/**
* @return string
*/
abstract public function getType();
/**
* @param DataTableInterface $table
* @param DataTableRequest $request
*
* @return void
*/
abstract public function applyFilter(DataTableInterface $table, DataTableRequest $request);
}
@@ -0,0 +1,41 @@
<?php
namespace Xentral\Widgets\DataTable\Filter;
use Closure;
use Xentral\Widgets\DataTable\DataTableInterface;
use Xentral\Widgets\DataTable\Request\DataTableRequest;
final class CustomFilter implements FilterInterface
{
/** @var Closure $closure */
private $closure;
/**
* @param Closure $closure
*/
public function __construct(Closure $closure)
{
$this->closure = $closure;
}
/**
* @return string
*/
public function getType()
{
return FilterInterface::TYPE_CUSTOM;
}
/**
* @param DataTableInterface $table
* @param DataTableRequest $request
*
* @return void
*/
public function applyFilter(DataTableInterface $table, DataTableRequest $request)
{
$closure = $this->closure;
$closure($table->getBaseQuery(), $request);
}
}
@@ -0,0 +1,84 @@
<?php
namespace Xentral\Widgets\DataTable\Filter;
use ArrayIterator;
use IteratorAggregate;
use JsonSerializable;
use Traversable;
class FilterCollection implements JsonSerializable, IteratorAggregate
{
/** @var array|FilterInterface[] $filters */
protected $filters = [];
/**
* @param array|FilterInterface[] $filters
*/
public function __construct(array $filters = [])
{
foreach ($filters as $filter) {
$this->add($filter);
}
}
/**
* @param FilterInterface $filter
*
* @return void
*/
public function add(FilterInterface $filter)
{
$this->filters[] = $filter;
}
/**
* @return array|FilterInterface[]
*/
public function all()
{
return $this->filters;
}
/**
* @return array
*/
public function toArray()
{
$result = [];
foreach ($this->filters as $filter) {
$result[] = $filter->toArray();
}
return $result;
}
/**
* @return array
*/
public function jsonSerialize()
{
return $this->toArray();
}
/**
* @return ArrayIterator|Traversable
*/
public function getIterator()
{
return new ArrayIterator($this->filters);
}
/**
* Deep copy object
*
* @return void
*/
public function __clone()
{
foreach ($this->filters as $index => $filter) {
$this->filters[$index] = clone $filter;
}
}
}
@@ -0,0 +1,34 @@
<?php
namespace Xentral\Widgets\DataTable\Filter;
use Xentral\Widgets\DataTable\DataTableInterface;
use Xentral\Widgets\DataTable\Request\DataTableRequest;
interface FilterInterface
{
/** @var string TYPE_TEXT */
const TYPE_TEXT = 'text';
/** @var string TYPE_NUMBER */
const TYPE_NUMBER = 'number';
/** @var string TYPE_NUMBER_RANGE */
const TYPE_NUMBER_RANGE = 'number_range';
/** @var string TYPE_CUSTOM */
const TYPE_CUSTOM = 'custom';
/**
* @return string
*/
public function getType();
/**
* @param DataTableInterface $table
* @param DataTableRequest $request
*
* @return void
*/
public function applyFilter(DataTableInterface $table, DataTableRequest $request);
}
@@ -0,0 +1,40 @@
<?php
namespace Xentral\Widgets\DataTable\Filter;
use Xentral\Widgets\DataTable\DataTableInterface;
use Xentral\Widgets\DataTable\Exception\FeatureNotImplementedException;
use Xentral\Widgets\DataTable\Request\DataTableRequest;
/**
* @deprecated Filter ist nocht nicht fertig
*/
final class NumberRangeFilter extends AbstractFilter
{
/**
* @throws FeatureNotImplementedException
*/
public function __construct()
{
throw new FeatureNotImplementedException('Filter type not implemented yet.');
}
/**
* @return string
*/
public function getType()
{
return FilterInterface::TYPE_NUMBER_RANGE;
}
/**
* @param DataTableInterface $table
* @param DataTableRequest $request
*
* @return void
*/
public function applyFilter(DataTableInterface $table, DataTableRequest $request)
{
// TODO: Implement applyFilter() method.
}
}
@@ -0,0 +1,125 @@
<?php
namespace Xentral\Widgets\DataTable\Filter;
use Xentral\Widgets\DataTable\DataTableInterface;
use Xentral\Widgets\DataTable\Exception\ColumnNotFoundException;
use Xentral\Widgets\DataTable\Exception\InvalidArgumentException;
use Xentral\Widgets\DataTable\Request\DataTableRequest;
class SingleWordTextFilter implements FilterInterface
{
/** @var string LIKE_EQUALS */
const LIKE_EQUALS = 'equals';
/** @var string LIKE_STARTS_WITH */
const LIKE_STARTS_WITH = 'startswith';
/** @var string LIKE_ENDS_WITH */
const LIKE_ENDS_WITH = 'endswith';
/** @var string LIKE_ANY */
const LIKE_ANY = 'any';
/** @var string $columnName*/
private $columnName;
/** @var string $filterName */
private $filterName;
/** @var string $likePattern */
private $likePattern;
/**
* @param string $columnName
* @param string $filterName
* @param string|null $likePattern
*/
public function __construct($columnName, $filterName, $likePattern = self::LIKE_ANY)
{
if ($likePattern !== null) {
$validLikePatterns = [self::LIKE_EQUALS, self::LIKE_STARTS_WITH, self::LIKE_ENDS_WITH, self::LIKE_ANY];
if (!in_array($likePattern, $validLikePatterns, true)) {
throw new InvalidArgumentException(sprintf(
'Like pattern "%s" is invalid. Valid patterns are: %s', $likePattern,
implode(', ', $validLikePatterns)
));
}
}
$this->columnName = $columnName;
$this->filterName = $filterName;
$this->likePattern = $likePattern;
}
/**
* @return string
*/
public function getFilterName()
{
return $this->filterName;
}
/**
* @return string|null
*/
public function getLikePattern()
{
return $this->likePattern;
}
/**
* @return string
*/
public function getType()
{
return FilterInterface::TYPE_TEXT;
}
/**
* @param DataTableInterface $table
* @param DataTableRequest $request
*
* @throws ColumnNotFoundException
*
* @return void
*/
public function applyFilter(DataTableInterface $table, DataTableRequest $request)
{
$column = $table->getColumns()->getByName($this->columnName);
if ($column === null || $column->getDbColumn() === null) {
throw new ColumnNotFoundException(sprintf(
'Can not apply text filter. Column "%s" is missing.',
$this->columnName
));
}
$filterValues = $request->getParams()->getFilterValues();
if (!array_key_exists($this->filterName, $filterValues)) {
return; // Filter param is not set
}
$filterValue = (string)$filterValues[$this->filterName];
if ($filterValue === '') {
return; // Filter value is empty
}
switch ($this->likePattern) {
case self::LIKE_EQUALS:
$filterCondition = $filterValue;
break;
case self::LIKE_STARTS_WITH:
$filterCondition = $filterValue . '%';
break;
case self::LIKE_ENDS_WITH:
$filterCondition = '%' . $filterValue;
break;
case self::LIKE_ANY:
default:
$filterCondition = '%' . $filterValue . '%';
break;
}
$table->getBaseQuery()->where($column->getDbColumn() . ' LIKE ?', $filterCondition);
}
}
@@ -0,0 +1,237 @@
<?php
namespace Xentral\Widgets\DataTable\Options;
use JsonSerializable;
final class DataTableOptions implements JsonSerializable
{
/** @var array $options Datatable initialisation options */
private $options;
/** @var array $defaultSorting */
private $defaultSorting = [];
/** @var array $postSorting */
private $postSorting = [];
/** @var array $preSorting */
private $preSorting = [];
/**
* @param array $options
*/
public function __construct(array $options = [])
{
$this->options = $this->getDefaults();
foreach ($options as $property => $value) {
$this->setOption($property, $value);
}
}
/**
* @param string $property
*
* @return bool
*/
public function hasOption($property)
{
return isset($this->options[(string)$property]);
}
/**
* @param string $property
* @param mixed|null $fallbackValue
*
* @return mixed|null
*/
public function getOption($property, $fallbackValue = null)
{
if ($this->hasOption($property)) {
return $this->options[(string)$property];
}
return $fallbackValue;
}
/**
* Sets/overwrites a property
*
* @param string $property
* @param mixed $value
*
* @return void
*/
public function setOption($property, $value)
{
$this->options[(string)$property] = $value;
}
/**
* Unsets a property
*
* @param $property
*
* @return void
*/
public function removeOption($property)
{
if ($this->hasOption($property)) {
unset($this->options[(string)$property]);
}
}
/**
* @return array
*/
public function getDefaultSorting()
{
return $this->defaultSorting;
}
/**
* @return array
*/
public function getPreSorting()
{
return $this->preSorting;
}
/**
* @return array
*/
public function getPostSorting()
{
return $this->postSorting;
}
/**
* Default-Sortierung; Benutzer-Sortierung überschreibt Default-Sortierung
*
* @example ['lagerbestand' => 'DESC', 'bezeichnung' => 'ASC']
*
* @param array $sorting
*
* @return void
*/
public function setDefaultSorting(array $sorting = [])
{
$this->defaultSorting = $sorting;
}
/**
* Feste Vor-Sortierung; kann vom Benutzer nicht geändert werden
*
* @example ['lagerbestand' => 'DESC', 'bezeichnung' => 'ASC']
*
* @param array $sorting
*
* @return void
*/
public function setPreSorting(array $sorting = [])
{
$this->preSorting = $sorting;
}
/**
* Feste Nach-Sortierung; kann vom Benutzer nicht geändert werden
*
* @example ['lagerbestand' => 'DESC', 'bezeichnung' => 'ASC']
*
* @param array $sorting
*
* @return void
*/
public function setPostSorting(array $sorting = [])
{
$this->postSorting = $sorting;
}
/**
* @return array
*/
public function toArray()
{
return $this->options;
}
/**
* @return array
*/
public function jsonSerialize()
{
return $this->toArray();
}
/**
* @return array
*/
private function getDefaults()
{
return [
'processing' => true,
'serverSide' => true,
'ajax' => [
'url' => null,
'type' => 'GET',
'data' => [],
],
'scrollX' => true,
'orderCellsTop' => true, // Handle sorting events only on first header row
'orderMulti' => true, // Multiple column ordering ability control
'dom' => $this->getDefaultDomTemplate(),
'language' => [
'emptyTable' => 'Keine Einträge gefunden',
'info' => 'Zeige _START_ bis _END_ von _TOTAL_ Einträgen',
'infoEmpty' => 'Zeile 0 bis 0 von 0 Einträgen',
'infoFiltered' => '(gefiltert aus insgesamt _MAX_ Einträgen)',
'infoPostFix' => '',
'decimal' => ',',
'thousands' => '.',
'lengthMenu' => '_MENU_ Einträge pro Seite',
'loadingRecords' => 'Lade...',
'processing' => 'Verarbeite...',
'search' => 'Suche:',
'zeroRecords' => 'Keine passenden Einträge gefunden',
'paginate' => [
'first' => '&#8676;',
'last' => '&#8677;',
'next' => '&raquo;',
'previous' => '&laquo;',
],
'aria' => [
'sortAscending' => ': Anklicken für aufsteigende Sortierung',
'sortDescending' => ': Anklicken für absteigende Sortierung',
],
],
// Plugins
'responsive' => false,
// Own config options
'autoinit' => true,
];
}
/**
* - l = Length changing input control ("Einträge pro Seite")
* - f = Filtering input (Search)
* - t = Table
* - i = Information summary ("Zeige 1 bis 10 von 14 Einträgen")
* - p = Pagination
* - r = Processing display element (Loading overlay)
* - B = Buttons
* - R = ColReorder (Column visibility)
*
* @see https://datatables.net/reference/option/dom
*
* @return string
*/
private function getDefaultDomTemplate()
{
return
"<'datatable-top'<'datatable-length'l><'datatable-search'f>" .
'r>t' .
"<'datatable-bottom'<'datatable-info'i><'datatable-buttons'B><'datatable-paginate'p>>";
}
}
@@ -0,0 +1,114 @@
<?php
namespace Xentral\Widgets\DataTable;
use Closure;
use Xentral\Components\Database\SqlQuery\SelectQuery;
use Xentral\Widgets\DataTable\Column\ColumnCollection;
use Xentral\Widgets\DataTable\Options\DataTableOptions;
use Xentral\Widgets\DataTable\Feature\FeatureCollection;
use Xentral\Widgets\DataTable\Filter\FilterCollection;
final class PreparedDataTable implements DataTableInterface
{
/** @var DataTableBuildConfig $config */
private $config;
/** @var SelectQuery $query */
private $query;
/** @var DataTableOptions $options */
private $options;
/** @var ColumnCollection $columns */
private $columns;
/** @var FeatureCollection $features */
private $features;
/** @var FilterCollection $filters */
private $filters;
/** @var Closure|null $customSearch @todo */
private $customSearch;
/**
* @param DataTableBuildConfig $config
* @param DataTableOptions $options
* @param SelectQuery $selectQuery
* @param ColumnCollection $columns
* @param FeatureCollection $features
* @param FilterCollection $filters
*/
public function __construct(
DataTableBuildConfig $config,
DataTableOptions $options,
SelectQuery $selectQuery,
ColumnCollection $columns,
FeatureCollection $features,
FilterCollection $filters
) {
$this->config = $config;
$this->options = $options;
$this->query = $selectQuery;
$this->columns = $columns;
$this->features = $features;
$this->filters = $filters;
}
/**
* @return DataTableBuildConfig
*/
public function getConfig()
{
return $this->config;
}
/**
* @return DataTableOptions
*/
public function getOptions()
{
return $this->options;
}
/**
* @return SelectQuery
*/
public function getBaseQuery()
{
return $this->query;
}
/**
* @return ColumnCollection
*/
public function getColumns()
{
return $this->columns;
}
/**
* @return FeatureCollection
*/
public function getFeatures()
{
return $this->features;
}
/**
* @return FilterCollection
*/
public function getFilters()
{
return $this->filters;
}
/**
* @return Closure|null @todo
*/
public function getCustomSearch()
{
return $this->customSearch;
}
}
@@ -0,0 +1,129 @@
<?php
namespace Xentral\Widgets\DataTable\Request;
use Xentral\Components\Http\Request;
use Xentral\Widgets\DataTable\Exception\InvalidArgumentException;
final class DataTableRequest
{
/** @var Request $request */
private $request;
/** @var DataTableRequestParameter $params */
private $params;
/**
* @param Request $request
* @param DataTableRequestParameter $parameter
*/
public function __construct(Request $request, DataTableRequestParameter $parameter)
{
$method = $request->getMethod();
if (!in_array($method, ['GET', 'POST'], true)) {
throw new InvalidArgumentException(sprintf(
'Can not create DataTableRequest instance. HTTP method "%s" is invalid.', $method
));
}
$this->request = $request;
$this->params = $parameter;
}
/**
* @param Request $request
*
* @return self
*/
public static function fromRequest(Request $request)
{
$parameters = DataTableRequestParameter::fromRequest($request);
return new self($request, $parameters);
}
/**
* @return bool
*/
public function isDataRequest()
{
if (!$this->isValidDataTableRequest()) {
return false;
}
if (!$this->isAjax()) {
return false;
}
return true;
}
/**
* @return bool
*/
private function isValidDataTableRequest()
{
if (empty($this->getParams()->getTableName())) {
return false;
}
if ($this->params->getDraw() < 1) {
return false;
}
if (empty($this->params->getColumnsValues()) ||
empty($this->params->getOrderValues()) ||
empty($this->params->getSearchValues())) {
return false;
}
return true;
}
/**
* @return bool
*/
public function isAjax()
{
return $this->request->isAjax();
}
/**
* @return string
*/
public function getMethod()
{
return $this->request->getMethod();
}
/**
* @return DataTableRequestParameter
*/
public function getParams()
{
return $this->params;
}
/**
* @return Request
*/
public function getOriginalRequest()
{
return $this->request;
}
/**
* @return bool
*/
public function isExportRequest()
{
if (!$this->isValidDataTableRequest()) {
return false;
}
if ($this->isAjax()) {
return false;
}
if (empty($this->params->getExportValues())) {
return false;
}
return true;
}
}
@@ -0,0 +1,166 @@
<?php
namespace Xentral\Widgets\DataTable\Request;
use Xentral\Components\Http\Request;
/**
* @see https://datatables.net/manual/server-side
*/
final class DataTableRequestParameter
{
/** @var string|null $tableName */
private $tableName;
/** @var int $draw Draw counter */
private $draw;
/** @var int $start Paging first record offset */
private $start;
/** @var int $length Number of records returned */
private $length;
/** @var array $columns Column settings and search queries */
private $columns;
/** @var array $search Global search query */
private $search;
/** @var array $order Ordering settings */
private $order;
/** @var array $filter Custom parameter for filter feature */
private $filter;
/** @var array $export Custom parameter for export feature */
private $export;
/**
* @param string $tableName
* @param int $draw
* @param int $start
* @param int $length
* @param array $columns
* @param array $search
* @param array $order
* @param array $filter
* @param array $export
*/
public function __construct(
$tableName = null,
$draw = 1,
$start = 0,
$length = 10,
$columns = [],
$search = [],
$order = [],
$filter = [],
$export = []
) {
$this->tableName = $tableName;
$this->draw = (int)$draw;
$this->start = (int)$start;
$this->length = (int)$length;
$this->columns = (array)$columns;
$this->search = (array)$search;
$this->order = (array)$order;
$this->filter = (array)$filter;
$this->export = (array)$export;
}
/**
* @param Request $request
*
* @return self
*/
public static function fromRequest(Request $request)
{
$params = $request->getMethod() === 'GET' ? $request->get : $request->post;
$tableName = $params->getAlphaNumWithDashes('tablename', null);
$draw = $params->getInt('draw', 1);
$start = $params->getInt('start', 0);
$length = $params->getInt('length', 10);
$columns = (array)$params->get('columns', []);
$search = (array)$params->get('search', []);
$order = (array)$params->get('order', []);
$filter = (array)$params->get('filter', []);
$export = (array)$params->get('export', []);
return new self($tableName, $draw, $start, $length, $columns, $search, $order, $filter, $export);
}
/**
* @return string|null
*/
public function getTableName()
{
return $this->tableName;
}
/**
* @return int
*/
public function getDraw()
{
return $this->draw;
}
/**
* @return int
*/
public function getStart()
{
return $this->start;
}
/**
* @return int
*/
public function getLength()
{
return $this->length;
}
/**
* @return array
*/
public function getSearchValues()
{
return $this->search;
}
/**
* @return array
*/
public function getOrderValues()
{
return $this->order;
}
/**
* @return array
*/
public function getColumnsValues()
{
return $this->columns;
}
/**
* @return array
*/
public function getFilterValues()
{
return $this->filter;
}
/**
* @return array
*/
public function getExportValues()
{
return $this->export;
}
}
@@ -0,0 +1,201 @@
<?php
namespace Xentral\Widgets\DataTable\Result;
use JsonSerializable;
use Xentral\Widgets\DataTable\Exception\InvalidArgumentException;
/**
* @see https://datatables.net/manual/server-side
*/
final class DataTableDataResult implements JsonSerializable
{
/** @var int $drawCounter The draw counter */
private $drawCounter;
/** @var int $recordsTotal Total number of records, before filtering */
private $recordsTotal;
/** @var int $recordsFiltered Total number of records, after filtering */
private $recordsFiltered;
/** @var array $data */
private $data;
/** @var string|null $errorMessage */
private $errorMessage;
/** @var array|null $debugInfo */
private $debugInfo;
/**
* @param int $drawCounter
* @param int $recordsTotal
* @param int $recordsFiltered
* @param array $data
*
* @throws InvalidArgumentException
*/
public function __construct(
$drawCounter = 1,
$recordsTotal = 0,
$recordsFiltered = 0,
$data = []
) {
if (!is_int($drawCounter)) {
throw new InvalidArgumentException(sprintf(
'Invalid argument. Draw counter must be an integer. Given type: %s',
strtolower(gettype($drawCounter))
));
}
if (!is_int($recordsTotal)) {
throw new InvalidArgumentException(sprintf(
'Invalid argument. Total records count must be an integer. Given type: %s',
strtolower(gettype($recordsTotal))
));
}
if (!is_int($recordsFiltered)) {
throw new InvalidArgumentException(sprintf(
'Invalid argument. Filtered records count be an integer. Given type: %s',
strtolower(gettype($recordsFiltered))
));
}
if (!is_array($data)) {
throw new InvalidArgumentException(sprintf(
'Invalid argument. Data parameter must be an array. Given type: %s',
strtolower(gettype($data))
));
}
$this->drawCounter = $drawCounter;
$this->recordsTotal = $recordsTotal;
$this->recordsFiltered = $recordsFiltered;
$this->data = $data;
}
/**
* @return array
*/
public function getResult()
{
$result = [
'draw' => $this->drawCounter,
'recordsTotal' => $this->recordsTotal,
'recordsFiltered' => $this->recordsFiltered,
'data' => $this->data,
];
if ($this->debugInfo !== null) {
$result['debug'] = $this->debugInfo;
}
if ($this->errorMessage !== null) {
$result['error'] = $this->errorMessage;
}
return $result;
}
/**
* @return int
*/
public function getDrawCounter()
{
return $this->drawCounter;
}
/**
* @return int
*/
public function getRecordsTotal()
{
return $this->recordsTotal;
}
/**
* @return int
*/
public function getRecordsFiltered()
{
return $this->recordsFiltered;
}
/**
* @return array
*/
public function getData()
{
return $this->data;
}
/**
* @return array|null
*/
public function getDebugInfo()
{
return $this->debugInfo;
}
/**
* @param array $debugInfo
*/
public function setDebugInfo($debugInfo)
{
if (!is_array($debugInfo)) {
throw new InvalidArgumentException(sprintf(
'Invalid argument. Debug information must be an array. Given type: %s',
strtolower(gettype($debugInfo))
));
}
$this->debugInfo = $debugInfo;
}
/**
* @return bool
*/
public function hasError()
{
return $this->errorMessage !== null;
}
/**
* @return string|null
*/
public function getErrorMessage()
{
return $this->errorMessage;
}
/**
* @param string $errorMessage
*
* @throws InvalidArgumentException
*/
public function setErrorMessage($errorMessage)
{
if (!is_string($errorMessage)) {
throw new InvalidArgumentException(sprintf(
'Invalid argument. Error message must be a string. Given type: %s',
strtolower(gettype($errorMessage))
));
}
$this->errorMessage = $errorMessage;
}
/**
* @return array
*/
public function toArray()
{
return $this->getResult();
}
/**
* @return array
*/
public function jsonSerialize()
{
return $this->toArray();
}
}
@@ -0,0 +1,91 @@
<?php
namespace Xentral\Widgets\DataTable\Result;
use Xentral\Widgets\DataTable\Exception\InvalidArgumentException;
final class DataTableHtmlResult
{
/** @var string $tableHtml */
private $tableHtml;
/** @var array $scriptOptions Initialization options for DataTable */
private $scriptOptions = [];
/**
* @param string $tableHtml
* @param array $scriptOptions
*
* @throws InvalidArgumentException
*/
public function __construct($tableHtml, array $scriptOptions)
{
if (empty($tableHtml)) {
throw new InvalidArgumentException('Required parameter "tableHtml" is empty.');
}
if (empty($scriptOptions)) {
throw new InvalidArgumentException('Required parameter "scriptOptions" is empty.');
}
$this->tableHtml = $tableHtml;
$this->scriptOptions = $scriptOptions;
}
/**
* @return string
*/
public function getResult()
{
return $this->getHtml();
}
/**
* @return string
*/
public function getHtml()
{
$html = '<div class="datatable-container">';
$html .= $this->getTableHtml();
$html .= $this->getScriptHtml();
$html .= '</div>';
return $html;
}
/**
* @return string
*/
public function getTableHtml()
{
return $this->tableHtml;
}
/**
* @return string
*/
public function getScriptHtml()
{
$optionsJsonString = json_encode(
$this->getScriptOptions(),
JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT
);
return sprintf('<script type="application/json">%s</script>', $optionsJsonString);
}
/**
* @return array
*/
public function getScriptOptions()
{
return $this->scriptOptions;
}
/**
* @return string
*/
public function __toString()
{
return $this->getHtml();
}
}
@@ -0,0 +1,221 @@
<?php
namespace Xentral\Widgets\DataTable\Service;
use Xentral\Components\Database\Database;
use Xentral\Widgets\DataTable\Column\ColumnCollection;
use Xentral\Widgets\DataTable\DataTableBuildConfig;
use Xentral\Widgets\DataTable\Exception\BuildFailedException;
use Xentral\Widgets\DataTable\Options\DataTableOptions;
use Xentral\Widgets\DataTable\DataTableInterface;
use Xentral\Widgets\DataTable\Feature\DataTableFeatureInterface;
use Xentral\Widgets\DataTable\Feature\FeatureCollection;
use Xentral\Widgets\DataTable\Filter\FilterCollection;
use Xentral\Widgets\DataTable\PreparedDataTable;
use Xentral\Widgets\DataTable\Type\DataTableTypeInterface;
final class DataTableBuilder
{
/** @var Database $database */
private $database;
/**
* @param Database $database
*/
public function __construct(Database $database)
{
$this->database = $database;
}
/**
* @param DataTableBuildConfig $config
*
* @throws BuildFailedException
*
* @return DataTableInterface
*/
public function buildTable(DataTableBuildConfig $config)
{
if (empty(trim($config->getTableName()))) {
throw new BuildFailedException('Build config is incomplete. Table name is empty.');
}
if (empty(trim($config->getAjaxUrl()))) {
throw new BuildFailedException('Build config is incomplete. Property "ajaxUrl" is missing.');
}
if (!class_exists($config->getTableClass(), true)) {
throw new BuildFailedException(sprintf('DataTable class "%s" not found', $config->getTableClass()));
}
$interfaces = class_implements($config->getTableClass(), true);
if (!in_array(DataTableTypeInterface::class, $interfaces, true)) {
throw new BuildFailedException(
'Can not build data table. Class does not implement ' . DataTableTypeInterface::class
);
}
/** @var DataTableTypeInterface $table */
$className = $config->getTableClass();
$table = new $className();
// @todo getParent() verarbeiten
$options = new DataTableOptions();
$table->configureOptions($options);
$columns = new ColumnCollection();
$table->configureColumns($columns);
$query = $this->database->select();
$table->configureQuery($query);
if ($query->hasOrderBy()) {
throw new BuildFailedException(
'Sorting in "configureQuery" will be overwritten. ' .
'Use "setDefaultSorting" in "configureOptions" instead.'
);
}
$features = new FeatureCollection();
$table->configureFeatures($features);
$filters = new FilterCollection();
$table->configureFilters($filters);
$preparedTable = new PreparedDataTable($config, $options, $query, $columns, $features, $filters);
$this->prepareTable($preparedTable);
return $preparedTable;
}
/**
* @param DataTableInterface $table
*
* @return void
*/
private function prepareTable(DataTableInterface $table)
{
$this->prepareColumns($table);
$this->applyFeatures($table);
$this->prepareSorting($table);
}
/**
* @param DataTableInterface $table
*
* @return void
*/
private function applyFeatures(DataTableInterface $table)
{
/** @var DataTableFeatureInterface $feature */
foreach ($table->getFeatures() as $feature) {
$feature->modifyTable($table);
}
}
/**
* @param DataTableInterface $table
*
* @return void
*/
private function prepareColumns(DataTableInterface $table)
{
// Spalten aus dem SQL-Query holen
$query = $table->getBaseQuery();
$columnNames = $query->getCols();
foreach ($columnNames as $alias => $fullColumnName) {
// Spalten mit Spaltenaliasen zuerst behandeln (easy)
$column = $table->getColumns()->getByName($alias);
if ($column !== null) {
$column->setDbColumn($fullColumnName);
continue;
}
// Tabellenalias aus Spaltenname entfernen
$shortColumnName = $this->extractNameFromColumn($fullColumnName);
$column = $table->getColumns()->getByName($shortColumnName);
if ($column !== null) {
$column->setDbColumn($fullColumnName);
}
}
}
/**
* @param DataTableInterface $table
*
* @return void
*/
private function prepareSorting(DataTableInterface $table)
{
$columnNames = array_column($table->getColumns()->toArray(), 'data');
$defaultSorting = $table->getOptions()->getDefaultSorting();
$postSorting = $table->getOptions()->getPostSorting();
$preSorting = $table->getOptions()->getPreSorting();
$defaultSorting = $this->translateSortingValues($columnNames, $defaultSorting);
$postSorting = $this->translateSortingValues($columnNames, $postSorting);
$preSorting = $this->translateSortingValues($columnNames, $preSorting);
/**
* Sortierung, wenn nichts gesetzt ist; Benutzer-Sortierung überschreibt diesen Wert
*
* @see https://datatables.net/reference/option/order
*/
if (empty($defaultSorting)) {
$defaultSorting = [[0, 'asc']];
}
$table->getOptions()->setOption('order', $defaultSorting);
/**
* Vor- und Nach-Sortierung; Kann vom Benutzer nicht geändert werden
*
* @see https://datatables.net/reference/option/orderFixed
*/
if (!empty($preSorting)) {
$orderFixed['pre'] = $preSorting;
}
if (!empty($postSorting)) {
$orderFixed['post'] = $postSorting;
}
if (!empty($orderFixed)) {
$table->getOptions()->setOption('orderFixed', $orderFixed);
} else {
$table->getOptions()->removeOption('orderFixed');
}
}
/**
* @example ['lagerbestand' => 'DESC', 'bezeichnung' => 'ASC'] wird zu [[3, 'desc'], [1, 'asc']]
*
* @param array $columnNames
* @param array $sortingValues
*
* @return array
*/
private function translateSortingValues($columnNames, $sortingValues)
{
$result = [];
foreach ($sortingValues as $columnName => $sortOrder) {
$columnIndex = array_search($columnName, $columnNames, true);
if ($columnIndex !== false) {
$result[] = [$columnIndex, strtolower($sortOrder)];
}
}
return $result;
}
/**
* @param string $column
*
* @return string
*/
private function extractNameFromColumn($column)
{
if ($pos = strrpos($column, '.')) {
return substr($column, $pos + 1);
}
return $column;
}
}
@@ -0,0 +1,575 @@
<?php
namespace Xentral\Widgets\DataTable\Service;
use Closure;
use Exception;
use Xentral\Components\Database\Database;
use Xentral\Components\Database\SqlQuery\SelectQuery;
use Xentral\Components\Exporter\Csv\CsvConfig;
use Xentral\Components\Exporter\Csv\CsvWriter;
use Xentral\Components\Exporter\Exception\InvalidResourceException;
use Xentral\Widgets\DataTable\Column\Column;
use Xentral\Widgets\DataTable\DataTableBuildConfig;
use Xentral\Widgets\DataTable\DataTableInterface;
use Xentral\Widgets\DataTable\Exception\InvalidArgumentException;
use Xentral\Widgets\DataTable\Feature\DebugFeature;
use Xentral\Widgets\DataTable\Feature\RowClassesFeature;
use Xentral\Widgets\DataTable\Filter\FilterInterface;
use Xentral\Widgets\DataTable\Request\DataTableRequest;
use Xentral\Widgets\DataTable\Result\DataTableDataResult;
final class DataTableFetcher
{
/** @var Database $db */
private $db;
/** @var DataTableRequest $request */
private $request;
/**
* @param Database $db
* @param DataTableRequest $request
*/
public function __construct(Database $db, DataTableRequest $request)
{
$this->db = $db;
$this->request = $request;
}
/**
* @param DataTableBuildConfig $buildConfig
*
* @return bool
*/
public function canFetchData(DataTableBuildConfig $buildConfig)
{
if (!$this->request->isAjax()) {
return false;
}
if ($this->request->getMethod() !== $buildConfig->getAjaxMethod()) {
return false;
}
$tableNameDefined = $buildConfig->getTableName();
$tableNameRequested = $this->request->getParams()->getTableName();
return $tableNameDefined === $tableNameRequested;
}
/**
* @param DataTableBuildConfig $buildConfig
*
* @return bool
*/
public function canExportData(DataTableBuildConfig $buildConfig)
{
$exportParams = (array)$this->request->getParams()->getExportValues();
if (empty($exportParams['format']) || empty($exportParams['result'])) {
return false;
}
if ($this->request->isAjax()) {
return false;
}
$tableNameDefined = $buildConfig->getTableName();
$tableNameRequested = $this->request->getParams()->getTableName();
return $tableNameDefined === $tableNameRequested;
}
/**
* @param DataTableInterface $table
*
* @return DataTableDataResult
*/
public function fetchData(DataTableInterface $table)
{
$startParam = $this->request->getParams()->getStart();
$lengthParam = $this->request->getParams()->getLength();
try {
$debugging = false;
if ($table->getFeatures()->has(DebugFeature::class)) {
/** @var DebugFeature $debugFeature */
$debugFeature = $table->getFeatures()->get(DebugFeature::class);
$debugging = $debugFeature->isEnabled();
}
if ($debugging === true) {
$debugData = ['profiler' => ['start' => microtime(true)]];
}
$baseQuery = $table->getBaseQuery();
$cols = $baseQuery->getCols();
// Set up query for total record count
$recordsTotalQuery = clone $baseQuery;
$recordsTotalQuery
->resetCols()
->cols([sprintf('COUNT(%s) AS num', $cols[0])]);
// Apply filters and searches
$this->applyFilters($table);
$this->applyColumnSearch($table, $baseQuery);
$this->applyGlobalSearch($table, $baseQuery);
// Set up query for data + limit result set
$dataQuery = clone $baseQuery;
$dataQuery->offset($startParam);
$dataQuery->limit($lengthParam);
if ($startParam === -1 || $lengthParam === -1) {
$dataQuery->offset(0);
$dataQuery->limit(0);
}
// Apply ORDER BY + LIMIT
$sortingValues = $this->prepareSortingValue($table);
$this->applySorting($dataQuery, $sortingValues);
$this->applyPaging($dataQuery, $startParam, $lengthParam);
// Set up query for filtered record count
// (= Record count with applied filters and searches)
$recordsFilteredQuery = clone $baseQuery;
$recordsFilteredQuery->resetCols()->cols([sprintf('COUNT(%s) AS num', $cols[0])]);
// Ergebnisanzahl; mit Filter
$recordsFiltered = $this->db->fetchValue(
$recordsFilteredQuery->getStatement(),
$recordsFilteredQuery->getBindValues()
);
// Ergebnisanzahl; ohne Filter
$recordsTotal = $this->db->fetchValue(
$recordsTotalQuery->getStatement(),
$recordsTotalQuery->getBindValues()
);
// Fetch data; displayed result
$data = $this->db->fetchAll(
$dataQuery->getStatement(),
$dataQuery->getBindValues()
);
// Column-Formatter anwenden
$columnFormatters = $table->getColumns()->getFormatters();
$this->applyColumnFormatters($data, $columnFormatters);
// Row-Formatter anwenden
// @todo In RowClassesFeature auslagern
if ($table->getFeatures()->has(RowClassesFeature::class)) {
/** @var RowClassesFeature $rowStyling */
$rowStyling = $table->getFeatures()->get(RowClassesFeature::class);
if ($rowStyling->hasCustomFormatter()) {
$rowFormatter = $rowStyling->getCustomFormatter();
foreach ($data as &$rowValues) {
$rowClasses = $rowStyling->getClassesString();
foreach ($rowFormatter as $closure) {
$rowClasses .= $closure($rowValues);
}
$rowValues['DT_RowClass'] = $rowClasses;
}
unset($rowValues);
}
}
// ID-Attribut für jede Zeile setzen
$tableName = $table->getConfig()->getTableName();
$this->appendRowIdAttribute($data, $tableName);
// Result-Objekt bauen
$result = new DataTableDataResult(
(int)$this->request->getParams()->getDraw(),
(int)$recordsTotal,
(int)$recordsFiltered,
(array)$data
);
} catch (Exception $exception) {
$result = new DataTableDataResult();
$result->setErrorMessage(sprintf(
'Unhandled exception: (%s) %s',
get_class($exception),
$exception->getMessage()
));
}
if ($debugging === true) {
$debugData['profiler']['finish'] = microtime(true);
$debugData['profiler']['duration_real'] = $debugData['profiler']['finish'] - $debugData['profiler']['start'];
$debugData['profiler']['duration'] = sprintf('%.6f', $debugData['profiler']['duration_real']) . ' seconds';
if (isset($dataQuery)) {
$debugData['query']['statement'] = $dataQuery->getStatement();
$debugData['query']['bindings'] = var_export($dataQuery->getBindValues(), true);
}
$result->setDebugInfo($debugData);
}
return $result;
}
/**
* @param DataTableInterface $table
*
* @return string Path to export file
*/
public function exportData(DataTableInterface $table)
{
$startParam = (int)$this->request->getParams()->getStart();
$lengthParam = (int)$this->request->getParams()->getLength();
$exportParams = (array)$this->request->getParams()->getExportValues();
$exportFormat = !empty($exportParams['format']) ? $exportParams['format'] : 'csv';
if ($exportFormat !== 'csv') {
throw new InvalidArgumentException(sprintf(
'Invalid export format "%s". Only "csv" is valid.', $exportFormat
));
}
$exportResult = !empty($exportParams['result']) ? $exportParams['result'] : 'page';
if (!in_array($exportResult, ['all', 'page'], true)) {
throw new InvalidArgumentException(sprintf(
'Invalid export result parameter value "%s". Valid values: %s',
$exportResult,
implode(', ', ['all', 'page'])
));
}
// Alle Ergebnisse exportieren
if ($exportResult === 'all') {
$startParam = -1;
$lengthParam = -1;
}
$fileName = uniqid('export-' . $table->getConfig()->getTableName(), false) . '.csv';
$filePath = sys_get_temp_dir() . '/' . $fileName;
$csv = @fopen($filePath, 'x+b');
if ($csv === false) {
throw new InvalidResourceException(sprintf('Failed to open resource for file path "%s".', $filePath));
}
$writer = new CsvWriter($csv, new CsvConfig());
$titles = [];
$dbCols = [];
foreach ($table->getColumns() as $column) {
/** @var Column $column */
if ($column->isExportable() && !empty($column->getDbColumn())) {
$titles[] = $column->getTitle();
$dbCols[] = $column->getDbColumn();
}
}
$writer->writeLine($titles);
$dataQuery = clone $table->getBaseQuery();
$dataQuery->resetCols()->cols($dbCols);
// Apply filters and searches
$this->applyFilters($table);
$this->applyColumnSearch($table, $dataQuery);
$this->applyGlobalSearch($table, $dataQuery);
// Apply ORDER BY
$sortingValues = $this->prepareSortingValue($table);
$this->applySorting($dataQuery, $sortingValues);
$itemsPerStep = 2500;
$currentOffset = 0;
$hasResults = true;
if ($exportResult === 'page') {
$itemsPerStep = $lengthParam;
$currentOffset = $startParam;
}
do {
$dataQuery->offset($currentOffset);
$dataQuery->limit($itemsPerStep);
$data = $this->db->yieldAll(
$dataQuery->getStatement(),
$dataQuery->getBindValues()
);
if (!$data->valid()) {
$hasResults = false;
continue;
}
$writer->writeLines($data);
$currentOffset += $itemsPerStep;
// Nach einer Iteration aufhören, wenn nur eine Seite exportiert werden soll
if ($exportResult === 'page') {
$hasResults = false;
}
} while ($hasResults);
fclose($csv);
return $filePath;
}
/**
* @param DataTableInterface $table
*
* @return void
*/
private function applyFilters(DataTableInterface $table)
{
/** @var FilterInterface $filter */
foreach ($table->getFilters() as $filter) {
$filter->applyFilter($table, $this->request);
}
}
/**
* Suche über das allgemeine Suchfeld verarbeiten (oben rechts)
*
* @param DataTableInterface $table
* @param SelectQuery $query
*
* @return void
*/
private function applyGlobalSearch(DataTableInterface $table, SelectQuery $query)
{
$searchValue = $this->getSearchParam();
if (empty($searchValue)) {
return;
}
$searchParts = explode(' ', $searchValue);
$searchParts = array_filter($searchParts, 'trim');
// Custom Search @todo Momentan ohne Funktion; Es gibt keine Möglichkeit zum Setzen der Einstellung
// Beispiel-Setter:
//$this->setCustomSearch(function (SelectQuery $query) {
// return $query
// ->cols(['artikel.id'])
// ->from('artikel')
// ->where('artikel.name_de LIKE :query')
// ->orWhere('artikel.name_en LIKE :query');
//});
//$customSearchClosure = $table->getCustomSearch();
//if ($customSearchClosure !== null) {
// $matchColumn = $query->getCols()[0];
// $customSearchQuery = $customSearchClosure($this->db->select());
//
// $query->joinSubSelect('inner', $customSearchQuery, 'matches', 'matches.id = ' . $matchColumn);
// $query->bindValue('query', '%' . $searchValue . '%');
//
// return;
//}
// Normale Suche
$searchableDbColumns = $table->getColumns()->getSearchableDbColumns();
foreach ($searchParts as $searchWord) {
$query->where(static function (SelectQuery $select) use ($searchableDbColumns, $searchWord) {
foreach ($searchableDbColumns as $searchDbColumn) {
$select->orWhere(sprintf('%s LIKE ?', $searchDbColumn), '%' . $searchWord . '%');
}
});
}
}
/**
* @todo In ColumnFilterFeature auslagern
*
* @param DataTableInterface $table
* @param SelectQuery $query
*
* @return void
*/
private function applyColumnSearch(DataTableInterface $table, SelectQuery $query)
{
//$columnFilter = $table->getFeatures()->get(ColumnFilterFeature::class);
$params = (array)$this->request->getParams()->getColumnsValues();
foreach ($params as $index => $param) {
$searchValue = $param['search']['value'];
if (empty($searchValue)) {
continue;
}
// Spaltensuche wurde ausgefüllt
$column = $table->getColumns()->getByName($param['name']);
if ($column === null) {
continue;
}
// Zahlenbereich-Suche
if (strpos($searchValue, 'number_range:') === 0) {
$searchPattern = str_replace([':null|', '|null'], '|', $searchValue);
if ($searchPattern === 'number_range:|') {
continue; // Leere Suche
}
$searchPattern = str_replace('number_range:', '', $searchPattern);
$searchParts = explode('|', $searchPattern);
if (count($searchParts) !== 2) {
continue;
}
$valueFrom = str_replace(',', '.', $searchParts[0]);
$valueTo = str_replace(',', '.', $searchParts[1]);
if (is_numeric($valueFrom)) {
$query->where($column->getDbColumn() . ' >= ?', (float)$valueFrom);
}
if (is_numeric($valueTo)) {
$query->where($column->getDbColumn() . ' <= ?', (float)$valueTo);
}
continue;
}
// Normale Textsuche
$query->where($column->getDbColumn() . ' LIKE ?', '%' . $searchValue . '%');
}
}
/**
* @see https://datatables.net/manual/server-side#Sent-parameters Parameters 'start' and 'length'
*
* @param SelectQuery $dataQuery
* @param int $startValue
* @param int $lengthValue
*
* @return void
*/
private function applyPaging(SelectQuery $dataQuery, $startValue, $lengthValue)
{
$dataQuery->offset($startValue);
$dataQuery->limit($lengthValue);
if ($startValue === -1 || $lengthValue === -1) {
$dataQuery->offset(0);
$dataQuery->limit(0);
}
}
/**
* @param SelectQuery $query
* @param array $sortingValues
*
* @return void
*/
private function applySorting(SelectQuery $query, array $sortingValues)
{
if (!empty($sortingValues)) {
$query->resetOrderBy();
foreach ($sortingValues as $sortColumn => $sortDirection) {
$query->orderBy([sprintf('%s %s', $sortColumn, strtoupper($sortDirection))]);
}
}
}
/**
* Column-Formatter anwenden
*
* @param array $data
* @param array $formatters
*
* @return void
*/
private function applyColumnFormatters(array &$data, array $formatters = [])
{
if (empty($formatters)) {
return;
}
foreach ($formatters as $colName => $formatter) {
if (!is_callable($formatter)) {
continue;
}
foreach ($data as &$rowData) {
$cellData = $rowData[$colName];
$newValue = $this->callColumnFormatter($formatter, $cellData, $rowData);
$rowData[$colName] = $newValue;
}
unset($rowData);
}
}
/**
* @param Closure $callback
* @param string $value
* @param array $rowValues
*
* @return string
*/
private function callColumnFormatter(Closure $callback, $value, $rowValues)
{
return $callback($value, $rowValues);
}
/**
* @param DataTableInterface $table
*
* @return array
*/
private function prepareSortingValue(DataTableInterface $table)
{
$orderValue = $this->getOrderParam();
$sorting = [];
foreach ($orderValue as $orderItem) {
$columnIndex = (int)$orderItem['column'];
$column = $table->getColumns()->getByIndex($columnIndex);
if ($column === null) {
break;
}
$columnName = $column->getDbColumn();
$sortDirection = in_array(strtolower($orderItem['dir']), ['asc', 'desc'], true)
? strtolower($orderItem['dir'])
: null;
if ($columnName !== null && $sortDirection !== null) {
$sorting[$columnName] = strtoupper($sortDirection);
}
}
return $sorting;
}
/**
* ID-Attribut für jede Zeile setzen
*
* @param array $data
* @param string $tableName
*
* @return void
*/
private function appendRowIdAttribute(&$data, $tableName)
{
// Row-ID hinzufügen
foreach ($data as &$rowValues) {
foreach ($rowValues as $key => &$value) {
if ($key === 'id') {
$rowValues['DT_RowId'] = sprintf('%s_row_%s', $tableName, $value);
}
}
unset($value);
}
unset($rowValues);
}
/**
* @return string
*/
private function getSearchParam()
{
return $this->request->getParams()->getSearchValues()['value'];
}
/**
* @return array
*/
private function getOrderParam()
{
return (array)$this->request->getParams()->getOrderValues();
}
}
@@ -0,0 +1,76 @@
<?php
namespace Xentral\Widgets\DataTable\Service;
use Xentral\Widgets\DataTable\Column\Column;
use Xentral\Widgets\DataTable\DataTableInterface;
use Xentral\Widgets\DataTable\Result\DataTableHtmlResult;
final class DataTableRenderer
{
/**
* @param DataTableInterface $table
*
* @return DataTableHtmlResult
*/
public function createHtmlResult(DataTableInterface $table)
{
return new DataTableHtmlResult($this->getHtmlTable($table), $this->getDataTableOptions($table));
}
/**
* @param DataTableInterface $table
*
* @return array
*/
private function getDataTableOptions(DataTableInterface $table)
{
$options = $table->getOptions()->toArray();
$options['ajax'] = [
'url' => $table->getConfig()->getAjaxUrl(),
'type' => $table->getConfig()->getAjaxMethod(),
'data' => $table->getConfig()->getAjaxParams(),
];
$options['columns'] = $table->getColumns()->toArray();
return $options;
}
/**
* @param DataTableInterface $table
*
* @return string
*/
private function getHtmlTable(DataTableInterface $table)
{
$columns = $table->getColumns();
$headerHtml1 = '';
$footerHtml = '';
/** @var Column $column */
foreach ($columns as $column) {
$headerHtml1 .= sprintf('<th data-name="%s">%s</th>', $column->getName(), $column->getTitle());
if ($column->has('footerHtml')) {
$footerHtml .= sprintf('<th data-name="%s">%s</th>', $column->getName(), $column->get('footerHtml'));
} else {
$footerHtml .= sprintf('<th data-name="%s">%s</th>', $column->getName(), $column->getTitle());
}
}
$html = "\n";
$html .= sprintf(
'<table id="%s" class="%s" width="100%%" data-autoinit="%s">',
$table->getConfig()->getTableName(),
$table->getConfig()->getCssClassesString(),
$table->getConfig()->isAutoInit() ? 'true' : 'false'
) . "\n";
$html .= '<thead>';
$html .= '<tr>' . $headerHtml1 . '</tr>';
$html .= '</thead>' . "\n";
$html .= '<tfoot><tr>' . $footerHtml . '</tr></tfoot>' . "\n";
$html .= '</table>' . "\n";
return $html;
}
}
@@ -0,0 +1,110 @@
<?php
namespace Xentral\Widgets\DataTable\Service;
use Xentral\Components\Http\FileResponse;
use Xentral\Components\Http\JsonResponse;
use Xentral\Components\Http\Response;
use Xentral\Widgets\DataTable\DataTableBuildConfig;
use Xentral\Widgets\DataTable\Request\DataTableRequest;
final class DataTableRequestHandler
{
/** @var DataTableService $service */
private $service;
/** @var DataTableRequest $request */
private $request;
/**
* @param DataTableService $service
* @param DataTableRequest $request
*/
public function __construct(DataTableService $service, DataTableRequest $request)
{
$this->service = $service;
$this->request = $request;
}
/**
* @param DataTableBuildConfig $config
*
* @return string
*/
public function generateHtml(DataTableBuildConfig $config)
{
return $this->service->renderHtml($config);
}
/**
* @param DataTableBuildConfig $config
*
* @return bool
*/
public function canHandleRequest(DataTableBuildConfig $config)
{
if ($this->request->getMethod() !== $config->getAjaxMethod()) {
return false;
}
if ($this->request->isDataRequest()) {
return true;
}
if ($this->request->isExportRequest()) {
return true;
}
return false;
}
/**
* @param DataTableBuildConfig $config
*
* @return Response
*/
public function handleRequest(DataTableBuildConfig $config)
{
if ($this->request->isDataRequest()) {
return $this->handleDataRequest($config);
}
if ($this->request->isExportRequest()) {
return $this->handleExportRequest($config);
}
return new JsonResponse([
'success' => false,
'error' => 'Can not fetch data from datatable. This is not a valid request.',
], Response::HTTP_BAD_REQUEST);
}
/**
* @param DataTableBuildConfig $config
*
* @return Response
*/
private function handleExportRequest(DataTableBuildConfig $config)
{
$filePath = $this->service->exportData($config);
return FileResponse::createFromFile($filePath, 'export.csv', 'text/csv', true);
}
/**
* @param DataTableBuildConfig $config
*
* @return Response
*/
private function handleDataRequest(DataTableBuildConfig $config)
{
if (!$this->service->canFetchData($config)) {
return new JsonResponse([
'success' => false,
'error' => 'Can not fetch data from datatable. Build config does not match with request parameters.',
], Response::HTTP_BAD_REQUEST);
}
$result = $this->service->fetchData($config);
$status = $result->hasError() ? Response::HTTP_INTERNAL_SERVER_ERROR : Response::HTTP_OK;
return new JsonResponse($result, $status);
}
}
@@ -0,0 +1,98 @@
<?php
namespace Xentral\Widgets\DataTable\Service;
use Xentral\Widgets\DataTable\DataTableBuildConfig;
use Xentral\Widgets\DataTable\DataTableInterface;
use Xentral\Widgets\DataTable\Result\DataTableDataResult;
use Xentral\Widgets\DataTable\Result\DataTableHtmlResult;
final class DataTableService
{
/** @var DataTableBuilder $builder */
private $builder;
/** @var DataTableRenderer $renderer */
private $renderer;
/** @var DataTableFetcher $fetcher */
private $fetcher;
/**
* @param DataTableBuilder $builder
* @param DataTableRenderer $renderer
* @param DataTableFetcher $fetcher
*/
public function __construct(DataTableBuilder $builder, DataTableRenderer $renderer, DataTableFetcher $fetcher)
{
$this->builder = $builder;
$this->renderer = $renderer;
$this->fetcher = $fetcher;
}
/**
* @param DataTableBuildConfig $buildConfig
*
* @return bool
*/
public function canFetchData(DataTableBuildConfig $buildConfig)
{
return $this->fetcher->canFetchData($buildConfig);
}
/**
* @param DataTableBuildConfig $buildConfig
*
* @return DataTableDataResult
*/
public function fetchData(DataTableBuildConfig $buildConfig)
{
$dataTable = $this->buildTable($buildConfig);
return $this->fetcher->fetchData($dataTable);
}
/**
* @param DataTableBuildConfig $buildConfig
*
* @return bool
*/
public function canExportData(DataTableBuildConfig $buildConfig)
{
return $this->fetcher->canExportData($buildConfig);
}
/**
* @param DataTableBuildConfig $buildConfig
*
* @return string Path to temporary file
*/
public function exportData(DataTableBuildConfig $buildConfig)
{
$dataTable = $this->buildTable($buildConfig);
return $this->fetcher->exportData($dataTable);
}
/**
* @param DataTableBuildConfig $buildConfig
*
* @return DataTableHtmlResult
*/
public function renderHtml(DataTableBuildConfig $buildConfig)
{
$dataTable = $this->buildTable($buildConfig);
return $this->renderer->createHtmlResult($dataTable);
}
/**
* @param DataTableBuildConfig $buildConfig
*
* @return DataTableInterface
*/
private function buildTable(DataTableBuildConfig $buildConfig)
{
return $this->builder->buildTable($buildConfig);
}
}
@@ -0,0 +1,91 @@
<?php
namespace Xentral\Widgets\DataTable\Type;
use Xentral\Components\Database\SqlQuery\SelectQuery;
use Xentral\Widgets\DataTable\Column\ColumnCollection;
use Xentral\Widgets\DataTable\Feature\DataTableFeatureInterface;
use Xentral\Widgets\DataTable\Feature\FeatureCollection;
use Xentral\Widgets\DataTable\Feature\ResponsiveFeature;
use Xentral\Widgets\DataTable\Feature\StateSaveFeature;
use Xentral\Widgets\DataTable\Feature\TableControlFeature;
use Xentral\Widgets\DataTable\Feature\TableStylingFeature;
use Xentral\Widgets\DataTable\Filter\FilterCollection;
use Xentral\Widgets\DataTable\Options\DataTableOptions;
abstract class AbstractDataTableType implements DataTableTypeInterface
{
/**
* @param DataTableOptions $options
*
* @return void
*/
public function configureOptions(DataTableOptions $options)
{
}
/**
* @param SelectQuery $query
*
* @return void
*/
public function configureQuery(SelectQuery $query)
{
}
/**
* @param ColumnCollection $columns
*
* @return void
*/
public function configureColumns(ColumnCollection $columns)
{
}
/**
* @param FeatureCollection $features
*
* @return void
*/
public function configureFeatures(FeatureCollection $features)
{
$this->addDefaultFeatures($features);
}
/**
* @param FilterCollection $filters
*
* @return void
*/
public function configureFilters(FilterCollection $filters)
{
}
/**
* @param FeatureCollection $featureCollection
*
* @return void
*/
protected function addDefaultFeatures(FeatureCollection $featureCollection)
{
foreach ($this->getDefaultFeatures() as $defaultFeature) {
$defaultFeatureClassName = get_class($defaultFeature);
if (!$featureCollection->has($defaultFeatureClassName)) {
$featureCollection->add($defaultFeature);
}
}
}
/**
* @return DataTableFeatureInterface[]|array
*/
private function getDefaultFeatures()
{
return [
new StateSaveFeature($enabled = true, $duration = 0),
new TableStylingFeature($compact = false, $noWrap = false),
new TableControlFeature(),
new ResponsiveFeature(),
];
}
}
@@ -0,0 +1,50 @@
<?php
namespace Xentral\Widgets\DataTable\Type;
use Xentral\Components\Database\SqlQuery\SelectQuery;
use Xentral\Widgets\DataTable\Column\ColumnCollection;
use Xentral\Widgets\DataTable\Options\DataTableOptions;
use Xentral\Widgets\DataTable\Feature\FeatureCollection;
use Xentral\Widgets\DataTable\Filter\FilterCollection;
interface DataTableTypeInterface
{
/** @var string|null PARENT_TABLE */
const PARENT_TABLE = null;
/**
* @param DataTableOptions $options
*
* @return void
*/
public function configureOptions(DataTableOptions $options);
/**
* @param SelectQuery $query
*
* @return void
*/
public function configureQuery(SelectQuery $query);
/**
* @param ColumnCollection $columns
*
* @return void
*/
public function configureColumns(ColumnCollection $columns);
/**
* @param FeatureCollection $features
*
* @return void
*/
public function configureFeatures(FeatureCollection $features);
/**
* @param FilterCollection $filters
*
* @return void
*/
public function configureFilters(FilterCollection $filters);
}
+62
View File
@@ -0,0 +1,62 @@
# DataTables
## Annahmen
* Nur falls Zeilen selektiert werden sollen:
* In jeder SQL-Abfrage muss die erste Spalte eine eindeutige ID zurückliefern.
* Diese Spalte muss den Namen `id` bekommen.
* Diese Spalte muss nicht als `Column` definiert werden.
* Jede Tabellenspalte benötigt einen eindeutigen Namen; für die Zuordnung von Filtern.
* Dieser Name korrespondiert mit einem SQL-Spaltennamen bzw. dem Alias. Beispiel:
`SELECT projekt.abkuerzung AS projekt_name ... ` dann muss der Spaltenname `projekt_name` und nicht `abkuerzung` heißen.
* Jede DataTable benötigt einen eindeutigen Namen.
* Für die Zuordnung von Filtern.
* Eindeutiger Name wird aus Klassenname generiert, wenn kein Name in der BuildConfig angegeben wird.
## Spaltenarten
Siehe `\Xentral\Widgets\DataTable\Column\Column` Klasse.
##### `Column::visible($name, $title, $align = 'left', $width = null)`
* Sichtbar
* Nicht sortierbar
* Nicht durchsuchbar
##### `Column::sortable($name, $title, $align = 'left', $width = null)`
* Sichtbar
* Sortierbar
* Nicht durchsuchbar
##### `Column::searchable($name, $title, $align = 'left', $width = null)`
* Sichtbar
* Sortierbar
* Durchsuchbar
##### `Column::fixed($name, $title, $align = 'left', $width = null)`
* Sichtbar
* Nicht sortierbar
* Nicht durchsuchbar
* Für Menü-Spalten und Zeilen-Selektion
##### `Column::hidden($name, $title, $align = 'left', $width = null)`
* Initial ausgeblendet; kann eingeblendet werden (Feature noch nicht implementiert)
* Nicht sortierbar
* Nicht durchsuchbar
## Aufbau
```
```
## Verwendung
### Vorlage
```php
```
@@ -0,0 +1,27 @@
<?php
namespace Xentral\Widgets\SuperSearch\Attachment;
abstract class AbstractAttachment implements AttachmentInterface
{
/**
* @return string
*/
abstract public function getType();
/**
* @return array
*/
abstract public function getData();
/**
* @return array
*/
public function jsonSerialize()
{
return [
'type' => $this->getType(),
'data' => $this->getData(),
];
}
}
@@ -0,0 +1,27 @@
<?php
namespace Xentral\Widgets\SuperSearch\Attachment;
use JsonSerializable;
interface AttachmentInterface extends JsonSerializable
{
/** @var string TYPE_BUTTON_BLOCK */
const TYPE_BUTTON_BLOCK = 'button_block';
/** @var string TYPE_CONTENT_STATIC */
const TYPE_CONTENT_STATIC = 'content_static';
/** @var string TYPE_CONTENT_DYNAMIC */
const TYPE_CONTENT_DYNAMIC = 'content_dynamic';
/**
* @return string
*/
public function getType();
/**
* @return array
*/
public function getData();
}
@@ -0,0 +1,41 @@
<?php
namespace Xentral\Widgets\SuperSearch\Attachment;
final class ButtonBlockAttachment extends AbstractAttachment
{
/** @var array $buttons */
private $buttons = [];
/**
* @inheritDoc
*/
public function getType()
{
return self::TYPE_BUTTON_BLOCK;
}
/**
* @inheritDoc
*/
public function getData()
{
return $this->buttons;
}
/**
* @todo Attribute validieren
*
* @param string $title
* @param array $attributes
*
* @return void
*/
public function addButton($title, array $attributes = [])
{
$this->buttons[] = [
'title' => (string)$title,
'attributes' => !empty($attributes) ? $attributes : null,
];
}
}
@@ -0,0 +1,41 @@
<?php
namespace Xentral\Widgets\SuperSearch\Attachment;
final class DynamicContentAttachment extends AbstractAttachment
{
/** @var string $ajaxUrl */
private $ajaxUrl;
/** @var array $postParams */
private $postParams = [];
/**
* @param string $ajaxUrl
* @param array $postParams
*/
public function __construct($ajaxUrl, array $postParams = [])
{
$this->ajaxUrl = (string)$ajaxUrl;
$this->postParams = $postParams;
}
/**
* @inheritDoc
*/
public function getType()
{
return self::TYPE_CONTENT_DYNAMIC;
}
/**
* @inheritDoc
*/
public function getData()
{
return [
'url' => $this->ajaxUrl,
'params' => $this->postParams,
];
}
}
@@ -0,0 +1,35 @@
<?php
namespace Xentral\Widgets\SuperSearch\Attachment;
final class StaticContentAttachment extends AbstractAttachment
{
/** @var string $content */
private $content;
/**
* @param string $content
*/
public function __construct($content)
{
$this->content = (string)$content;
}
/**
* @inheritDoc
*/
public function getType()
{
return self::TYPE_CONTENT_STATIC;
}
/**
* @inheritDoc
*/
public function getData()
{
return [
'content' => $this->content,
];
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace Xentral\Widgets\SuperSearch;
final class Bootstrap
{
/**
* @return array
*/
public static function registerJavascript()
{
return [
'supersearch' => [
'./classes/Widgets/SuperSearch/www/js/supersearch.js',
],
];
}
/**
* @return array
*/
public static function registerStylesheets()
{
return [
'supersearch' => [
'./classes/Widgets/SuperSearch/www/css/supersearch.css',
],
];
}
}
@@ -0,0 +1,9 @@
<?php
namespace Xentral\Widgets\SuperSearch\Exception;
use InvalidArgumentException as SplInvalidArgumentException;
class InvalidArgumentException extends SplInvalidArgumentException implements SuperSearchExceptionInterface
{
}
@@ -0,0 +1,9 @@
<?php
namespace Xentral\Widgets\SuperSearch\Exception;
use Xentral\Core\Exception\WidgetExceptionInterface;
interface SuperSearchExceptionInterface extends WidgetExceptionInterface
{
}
@@ -0,0 +1,55 @@
<?php
namespace Xentral\Widgets\SuperSearch\Query;
use Xentral\Widgets\SuperSearch\Exception\InvalidArgumentException;
final class DetailQuery
{
/** @var string $groupKey */
private $groupKey;
/** @var int|string $itemIdentifier */
private $itemIdentifier;
/**
* @param string $groupKey
* @param int|string $itemIdentifier
*
* @throws InvalidArgumentException
*/
public function __construct($groupKey, $itemIdentifier)
{
$groupKeyCleaned = (string)preg_replace('#[^a-z_]#', '', $groupKey);
if ($groupKey !== $groupKeyCleaned) {
throw new InvalidArgumentException(
'Invalid characters in $groupKey. Allowed characters: a-z and underscore.'
);
}
if (!is_int($itemIdentifier) && !is_string($itemIdentifier)) {
throw new InvalidArgumentException(sprintf(
'Invalid argument type for $itemIdentifier. Only integer or string is allowed. Type given: %s.',
gettype($itemIdentifier)
));
}
$this->groupKey = (string)$groupKey;
$this->itemIdentifier = $itemIdentifier;
}
/**
* @return string
*/
public function getGroupKey()
{
return $this->groupKey;
}
/**
* @return int|string
*/
public function getItemIdentifier()
{
return $this->itemIdentifier;
}
}
@@ -0,0 +1,48 @@
<?php
namespace Xentral\Widgets\SuperSearch\Query;
use Xentral\Widgets\SuperSearch\Exception\InvalidArgumentException;
final class SearchQuery
{
/** @var string $searchTerm */
private $searchTerm;
/** @var array $searchWords */
private $searchWords = null;
/**
* @param string $searchTerm
*
* @throws InvalidArgumentException
*/
public function __construct($searchTerm)
{
if (empty($searchTerm)) {
throw new InvalidArgumentException('Parameter "searchTerm" is empty.');
}
$this->searchTerm = trim($searchTerm);
}
/**
* @return string
*/
public function getSearchTerm()
{
return $this->searchTerm;
}
/**
* @return array
*/
public function getSearchWords()
{
if ($this->searchWords === null) {
$this->searchWords = (array)preg_split('/([\s]+)/um', $this->searchTerm, -1, PREG_SPLIT_NO_EMPTY);
}
return $this->searchWords;
}
}
@@ -0,0 +1,108 @@
<?php
namespace Xentral\Widgets\SuperSearch\Result;
use DateTimeInterface;
use JsonSerializable as JsonSerializableAlias;
final class ResultCollection implements JsonSerializableAlias
{
/** @var ResultGroup[]|array $groups */
private $groups;
/** @var DateTimeInterface|null $lastIndexUpdateTime */
private $lastIndexUpdateTime;
/**
* @param ResultGroup[]|array $resultGroups
* @param DateTimeInterface|null $lastIndexUpdate
*/
public function __construct(array $resultGroups = [], DateTimeInterface $lastIndexUpdate = null)
{
foreach ($resultGroups as $resultGroup) {
$this->addGroup($resultGroup);
}
$this->lastIndexUpdateTime = $lastIndexUpdate;
}
/**
* @return bool
*/
public function isEmpty()
{
return count($this->groups) === 0;
}
/**
* @param ResultGroup $group
*
* @return void
*/
public function addGroup(ResultGroup $group)
{
$this->groups[] = $group;
}
/**
* @param string $groupKey
*
* @return bool
*/
public function hasGroup($groupKey)
{
foreach ($this->groups as $group) {
if ($groupKey === $group->getKey()) {
return true;
}
}
return false;
}
/**
* @param string $groupKey
*
* @return ResultGroup|null
*/
public function getGroup($groupKey)
{
foreach ($this->groups as $group) {
if ($groupKey === $group->getKey()) {
return $group;
}
}
return null;
}
/**
* @return DateTimeInterface|null
*/
public function getLastIndexUpdateTime()
{
return $this->lastIndexUpdateTime;
}
/**
* @return array
*/
public function jsonSerialize()
{
$results = [];
$itemCount = 0;
foreach ($this->groups as $group) {
$itemCount += $group->countItems();
$index = $group->getKey();
$results[$index] = $group;
}
return [
'count' => $itemCount,
'results' => $results,
'last_index_update_rfc2822' =>
$this->lastIndexUpdateTime !== null ? $this->lastIndexUpdateTime->format(DATE_RFC2822) : null,
'last_index_update_formatted' =>
$this->lastIndexUpdateTime !== null ? $this->lastIndexUpdateTime->format('d.m.Y H:i') . ' Uhr' : null,
];
}
}
@@ -0,0 +1,139 @@
<?php
namespace Xentral\Widgets\SuperSearch\Result;
use JsonSerializable;
use Xentral\Widgets\SuperSearch\Attachment\ButtonBlockAttachment;
use Xentral\Widgets\SuperSearch\Attachment\DynamicContentAttachment;
use Xentral\Widgets\SuperSearch\Attachment\StaticContentAttachment;
use Xentral\Widgets\SuperSearch\Exception\InvalidArgumentException;
final class ResultDetail implements JsonSerializable
{
/** @var string $title */
private $title = '';
/** @var ButtonBlockAttachment|null $buttons */
private $buttons;
/** @var StaticContentAttachment|null $staticDescription */
private $staticDescription;
/** @var DynamicContentAttachment|null $dynamicDescription */
private $dynamicDescription;
/**
* @return bool
*/
public function hasTitle()
{
return !empty($this->title);
}
/**
* @return string
*/
public function getTitle()
{
return $this->title;
}
/**
* @param string $title
*/
public function setTitle($title)
{
if (empty($title)) {
throw new InvalidArgumentException('Required parameter "title" is empty.');
}
$this->title = $title;
}
/**
* Beschreibung setzen; MiniDetail wird dann geleert!
*
* @param string $description
*
* @throws InvalidArgumentException
*/
public function setDescription($description)
{
if (empty($description)) {
throw new InvalidArgumentException('Required parameter $description is empty.');
}
$this->staticDescription = new StaticContentAttachment($description);
$this->dynamicDescription = null;
}
/**
* MiniDetail-URL setzen; Beschreibung wird dann geleert!
*
* @param string $miniDetailUrl
* @param array $postParams
*/
public function setMiniDetailUrl($miniDetailUrl, array $postParams = [])
{
if (empty($miniDetailUrl)) {
throw new InvalidArgumentException('Required parameter $miniDetailUrl is empty.');
}
$this->dynamicDescription = new DynamicContentAttachment($miniDetailUrl, $postParams);
$this->staticDescription = null; // Entweder MiniDetail oder Beschreibungstext
}
/**
* @param string $title
* @param string|null $href Hyperlink reference
* @param array $attributes
*
* @return void
*/
public function addButton($title, $href = null, array $attributes = [])
{
if ($this->buttons === null) {
$this->buttons = new ButtonBlockAttachment();
}
if ($href !== null) {
$attributes['href'] = (string)$href;
}
$this->buttons->addButton($title, $attributes);
}
/**
* @return bool
*/
public function isValid()
{
return $this->hasTitle();
}
/**
* @return array|false
*/
public function jsonSerialize()
{
if (!$this->isValid()) {
return false;
}
$attachments = [];
if ($this->buttons !== null) {
$attachments[] = $this->buttons;
}
if ($this->staticDescription !== null) {
$attachments[] = $this->staticDescription;
}
if ($this->dynamicDescription !== null) {
$attachments[] = $this->dynamicDescription;
}
return [
'title' => $this->title,
'attachments' => $attachments,
];
}
}
@@ -0,0 +1,85 @@
<?php
namespace Xentral\Widgets\SuperSearch\Result;
final class ResultGroup implements \JsonSerializable
{
/** @var string $key */
private $key;
/** @var string $title */
private $title;
/** @var ResultItem[]|array $items */
private $items = [];
/**
* @param string $groupKey
* @param string $groupTitle
* @param ResultItem[]|array $resultItems
*/
public function __construct($groupKey, $groupTitle, array $resultItems = [])
{
$this->key = $groupKey;
$this->title = $groupTitle;
foreach ($resultItems as $resultItem) {
$this->addItem($resultItem);
}
}
/**
* @param ResultItem $item
*
* @return void
*/
public function addItem(ResultItem $item)
{
$this->items[] = $item;
}
/**
* @return string
*/
public function getKey()
{
return $this->key;
}
/**
* @return string
*/
public function getTitle()
{
return $this->title;
}
/**
* @return ResultItem[]|array
*/
public function getItems()
{
return $this->items;
}
/**
* @return int
*/
public function countItems()
{
return count($this->items);
}
/**
* @return array
*/
public function jsonSerialize()
{
return [
'key' => $this->key,
'title' => $this->title,
'count' => count($this->items),
'items' => $this->items,
];
}
}
@@ -0,0 +1,128 @@
<?php
namespace Xentral\Widgets\SuperSearch\Result;
use JsonSerializable;
use Xentral\Widgets\SuperSearch\Exception\InvalidArgumentException;
final class ResultItem implements JsonSerializable
{
/** @var int|string $identifier */
private $identifier;
/** @var string $title */
private $title;
/** @var string $link */
private $link;
/** @var string|null $subTitle */
private $subTitle;
/** @var array|string[] $additionalInfos */
private $additionalInfos = [];
/**
* @param int|string $identifier Database-ID or unique keyword
* @param string $title
* @param string $link
* @param string|null $subTitle
* @param array|null $additionalInfos
*/
public function __construct($identifier, $title, $link, $subTitle = null, array $additionalInfos = null)
{
if (empty($identifier)) {
throw new InvalidArgumentException('Parameter "id" is empty.');
}
if (empty($title)) {
throw new InvalidArgumentException('Parameter "title" is empty.');
}
if (empty($link)) {
throw new InvalidArgumentException('Parameter "link" is empty.');
}
$this->identifier = $identifier;
$this->title = (string)$title;
$this->link = (string)$link;
$subTitle = trim($subTitle);
if ($subTitle !== '') {
$this->subTitle = $subTitle;
}
$additionalInfos = (array)$additionalInfos;
foreach ($additionalInfos as $additionalInfo) {
$additionalInfo = trim($additionalInfo);
if ($additionalInfo !== '') {
$this->additionalInfos[] = $additionalInfo;
}
}
}
/**
* @param array $state
*
* @return self
*/
public static function fromDbState(array $state)
{
$additionalInfos = explode(' ## ' , $state['additional_infos']);
return new self($state['index_id'], $state['title'], $state['link'], $state['subtitle'], $additionalInfos);
}
/**
* @return int
*/
public function getIdentifier()
{
return $this->identifier;
}
/**
* @return string
*/
public function getTitle()
{
return $this->title;
}
/**
* @return string
*/
public function getLink()
{
return $this->link;
}
/**
* @return string|null
*/
public function getSubTitle()
{
return $this->subTitle;
}
/**
* @return array
*/
public function getAdditionalInfos()
{
return $this->additionalInfos;
}
/**
* @return array
*/
public function jsonSerialize()
{
return [
'type' => 'default',
'identifier' => $this->identifier,
'title' => $this->title,
'link' => $this->link,
'subtitle' => $this->subTitle,
'additionalInfos' => !empty($this->additionalInfos) ? $this->additionalInfos : null,
];
}
}
@@ -0,0 +1,269 @@
/**
SuperSearch
*/
#supersearch-overlay {
display: none;
position: absolute;
z-index: 994;
top: 55px;
left: 18px;
width: 250px;
height: 440px;
background-color: var(--body-background);
box-shadow: 3px 3px 10px rgba(0, 0, 0, .33);
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
#supersearch-overlay.has-detail {
width: 890px;
}
#supersearch-overlay #supersearch-icon-close {
z-index: 981;
cursor: pointer;
display: block;
position: absolute;
top: 24px;
right: 24px;
width: 24px;
height: 24px;
background-image: url('../themes/new/images/icon-close.svg');
background-position: center;
background-repeat: no-repeat;
background-size: 80%;
}
#supersearch-overlay .result-wrapper {
overflow: auto;
width: 250px;
height: 416px;
background-color: var(--body-background);
}
#supersearch-overlay .detail-wrapper {
display: none;
position: absolute;
top: 0;
left: 250px;
width: 640px;
height: 440px;
overflow-y: auto;
background-color: var(--fieldset);
}
#supersearch-overlay.has-detail .detail-wrapper {
display: block;
}
#supersearch-overlay .search {
display: none;
height: 45px;
padding: 10px;
background-color: var(--body-background);
}
#supersearch-overlay .empty-message,
#supersearch-overlay .error-message {
display: none;
padding: 10px;
}
#supersearch-overlay .error-message {
font-weight: bold;
color: darkred;
}
#supersearch-overlay .result {
display: block;
padding: 0;
background-color: var(--body-background);
}
#supersearch-overlay .last-update {
display: block;
position: absolute;
bottom: 0;
left: 0;
right: 0;
width: 250px;
padding: 6px 12px;
font-size: 10px;
color: var(--text-color);
background-color: var(--fieldset-dark);
}
#supersearch-overlay .detail {
padding: 0;
margin: 10px 28px;
line-height: 1.428;
}
#supersearch-overlay .detail img {
max-width: 80%;
height: 12px;
}
#supersearch-overlay .detail .minidetail {
overflow: auto;
}
#supersearch-overlay .result-empty {
padding: 4px 6px 3px 12px;
margin-bottom: 6px;
}
#supersearch-overlay .result-list {
list-style-type: none;
padding: 0;
margin: 0 0 6px 0;
}
#supersearch-overlay .result-head,
#supersearch-overlay .result-foot,
#supersearch-overlay .result-item {
list-style-type: none;
padding: 0;
margin: 0;
}
#supersearch-overlay .result-head {
text-transform: uppercase;
font-weight: bold;
}
#supersearch-overlay .result-foot {
font-style: italic;
}
#supersearch-overlay .result-item {
font-weight: normal;
}
#supersearch-overlay .result-head,
#supersearch-overlay .result-foot {
padding: 4px 6px 3px 12px;
margin-bottom: 6px;
}
#supersearch-overlay .result-head {
background-color: var(--fieldset-dark);
}
#supersearch-overlay .result-item a,
#supersearch-overlay .result-item a:link,
#supersearch-overlay .result-item a:visited,
#supersearch-overlay .result-item a:hover,
#supersearch-overlay .result-item a:active {
display: block;
padding: 3px 6px 3px 12px;
}
#supersearch-overlay .result-item a:hover {
color: var(--grey);
background-color: rgba(0, 0, 0, .15);
}
#supersearch-overlay .result-item.active {
color: var(--grey);
background-color: rgba(0, 0, 0, .15);
}
#supersearch-overlay .result-item .title,
#supersearch-overlay .result-item .caption {
display: flex;
flex-flow: row nowrap;
width: 100%;
}
#supersearch-overlay .result-item .caption {
font-size: .9em;
color: var(--text-color);
margin-top: 2px;
}
#supersearch-overlay .result-item .title span,
#supersearch-overlay .result-item .caption span {
flex: 1 33%;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 2px;
}
#supersearch-overlay .result-item .caption span:last-of-type {
text-align: right;
}
#supersearch-overlay .result-item .caption span:first-of-type {
text-align: left;
}
#supersearch-overlay .result-item .title .title-main {
flex: 1 33%;
}
#supersearch-overlay .result-item .title .title-sub {
flex: 2 66%;
}
#supersearch-overlay .detail h1,
#supersearch-overlay .detail h2,
#supersearch-overlay .detail h3,
#supersearch-overlay .detail h4,
#supersearch-overlay .detail h5,
#supersearch-overlay .detail h6,
#supersearch-overlay .detail p {
padding: 0;
margin: 8px 0;
}
#supersearch-overlay .detail h1 {
margin-top: 24px;
}
#supersearch-overlay .detail h2 {
margin-top: 20px;
}
#supersearch-overlay .detail h3 {
margin-top: 16px;
}
#supersearch-overlay .detail h4 {
margin-top: 12px;
}
#supersearch-overlay .detail ul {
padding: 0 0 0 20px;
margin: 12px 0;
}
#supersearch-overlay .detail ul ul {
margin: 0;
}
#supersearch-overlay .detail ul li a {
display: inline-block;
padding: 3px 0;
text-decoration: underline;
}
#supersearch-overlay .detail .button .icon {
display: inline-block;
width: 16px;
height: 12px;
margin: 0 4px 0 0;
}
#supersearch-overlay .detail .button .icon img {
display: inline-block;
max-width: 100%;
width: 16px;
height: 12px;
padding: 0;
margin: 0;
vertical-align: bottom;
}
/** Overlay ausblenden wenn Sidebar und Suchfeld nicht sichtbar */
@media screen and (max-width: 1000px) {
#supersearch-overlay {
display: none;
visibility: hidden;
}
}
/** Overlay um 200 Pixel verbreitern, wenn genug Platz vorhanden */
@media screen and (min-width: 1200px) {
#supersearch-overlay.has-detail {
width: 1090px;
}
#supersearch-overlay .detail-wrapper {
width: 840px;
}
}
@@ -0,0 +1,642 @@
var SuperSearch = (function ($) {
'use strict';
var me = {
config: {
inputBuffer: 300 // in milliseconds
},
storage: {
$input: null,
$overlay: null,
$details: null,
$results: null,
$lastUpdate: null,
debounceBuffer: null,
hasResults: false,
isOpen: false
},
init: function () {
me.storage.$input = $('#supersearch-input');
if (me.storage.$input.length === 0) {
return;
}
me.registerEvents();
},
registerEvents: function () {
me.storage.$input.on('keyup.SuperSearch', me.onKeyUpSearchInput);
// Overlay anzeigen bei Focus in das Such-Eingabefeld; nur wenn es schon mal geöffnet war
me.storage.$input.on('focus.SuperSearch', me.onFocusSearchInput);
// Overlay mit ESC schließen
$(document).bind('keydown', function(e) {
if (me.storage.$overlay === null) {
return;
}
if (me.storage.isOpen !== true) {
return;
}
// ESC
if (e.keyCode === 27) {
me.hideOverlay();
}
});
},
/**
* @return {jQuery}
*/
getOverlay: function () {
if (typeof me.storage.$overlay === 'undefined' || me.storage.$overlay === null) {
me.storage.$overlay = me.createOverlay();
me.storage.$details = me.storage.$overlay.find('section.detail');
me.storage.$results = me.storage.$overlay.find('section.result');
me.storage.$lastUpdate = me.storage.$overlay.find('section.last-update');
}
return me.storage.$overlay;
},
showOverlay: function () {
var $overlay = me.getOverlay();
$overlay.show();
me.storage.isOpen = true;
me.showDetails();
},
hideOverlay: function () {
me.getOverlay().hide();
me.storage.isOpen = false;
},
/**
* @return {jQuery}
*/
createOverlay: function () {
var overlaySelector = '#supersearch-overlay';
if ($(overlaySelector).length > 0) {
return $(overlaySelector);
}
var overlayTemplate =
'<span id="supersearch-icon-close" class="icon icon-close"></span>' +
'<div class="result-wrapper">' +
'<section class="empty-message">Keine Suchergebnisse gefunden</section>' +
'<section class="error-message"></section>' +
'<section class="result"></section>' +
'<section class="last-update"></section>' +
'</div>' +
'<div class="detail-wrapper">' +
'<section class="detail"></section>' +
'</div>';
var overlayIdAttr = overlaySelector.substr(1);
var $overlay = $('<div>').attr('id', overlayIdAttr).addClass('supersearch-overlay').html(overlayTemplate);
$overlay.off('click.SuperSearch', '#supersearch-icon-close');
$overlay.on('click.SuperSearch', '#supersearch-icon-close', function (event) {
event.preventDefault();
me.hideOverlay();
});
$overlay.hide();
$overlay.appendTo('#header');
me.storage.isOpen = false;
return $overlay;
},
/**
* @param {Event} event
*/
onKeyUpSearchInput: function (event) {
event.preventDefault();
var controlKeyCodes = [
9, // Tab
13, // Enter
16, // Shift
17, // Strg
18, // Alt
20, // Caps lock
27, // ESC
37, // Cursor Left
38, // Cursor Up
39, // Cursor Right
40 // Cursor Down
];
if ($.inArray(event.keyCode, controlKeyCodes) !== -1) {
return;
}
var that = this;
me.debounce(function () {
var searchQuery = $(that).val();
me.fetchSearchResults(searchQuery).then(me.renderSearchResults);
}, me.config.inputBuffer);
},
/**
* Overlay anzeigen bei Focus in das Such-Eingabefeld; nur wenn es schon mal geöffnet war
*
* @param {Event} event
*/
onFocusSearchInput: function (event) {
event.preventDefault();
if (me.storage.$overlay === null) {
return;
}
if (me.storage.hasResults === false) {
return;
}
me.showOverlay();
},
/**
* @param {string} searchQuery
*
* @return {jqXHR}
*/
fetchSearchResults: function (searchQuery) {
if (typeof searchQuery !== 'string') {
searchQuery = '';
}
return $.ajax({
url: 'index.php?module=supersearch&action=ajax&cmd=search',
method: 'post',
dataType: 'json',
data: {
search_query: searchQuery
},
error: function (jqXHR, textStatus, errorThrown) {
var errorMessage = 'SuperSearch - Unbekannter Fehler #31: ' + errorThrown;
// PHP-Skript hat Fehler geliefert (z.b. 404)
if (textStatus === 'error') {
errorMessage = 'SuperSearch - Unbekannter Server-Fehler beim Laden der Such-Ergebnisse: ';
errorMessage += errorThrown;
}
// PHP-Skript liefert JSON-Error-Response
if (jqXHR.hasOwnProperty('responseJSON') && jqXHR.responseJSON.hasOwnProperty('error')) {
errorMessage = 'SuperSearch - Server-Fehler beim Laden der Such-Ergebnisse: ';
errorMessage += jqXHR.responseJSON.error;
if (jqXHR.responseJSON.hasOwnProperty('data') &&
jqXHR.responseJSON.data === 'index-empty') {
me.showErrorMessage('Fehler: ' + jqXHR.responseJSON.error);
return;
}
}
alert(errorMessage);
}
});
},
/**
* @param {Array} rawResult
*/
renderSearchResults: function (rawResult) {
var $overlay = me.getOverlay();
var $resultContainer = $overlay.find('section.result');
$resultContainer.html('');
if (rawResult.length === 0 || !rawResult.hasOwnProperty('data')) {
$resultContainer.html('Fehler: Suche hat fehlerhaftes Ergebnis geliefert.');
me.storage.hasResults = false;
me.hideResults();
return;
}
// Overlay ausblenden, wenn Suchbegriff zu kurz
if (rawResult.data === null) {
me.storage.hasResults = false;
me.hideOverlay();
return;
}
// Anzeigen wann der Such-Index das letzte Mal aktualisiert wurde
if (rawResult.data.hasOwnProperty('last_index_update_formatted')) {
if (rawResult.data.last_index_update_formatted !== null) {
var lastIndexUpdate = rawResult.data.last_index_update_formatted;
me.storage.$lastUpdate.text('Such-Index vom ' + lastIndexUpdate).show();
} else {
me.storage.$lastUpdate.text('').hide();
}
}
var resultCount = rawResult.data.count;
var searchResults = rawResult.data.results;
if (resultCount === 0) {
me.storage.hasResults = false;
me.showEmptyResults();
return;
}
me.storage.$details.html('');
Object.keys(searchResults).forEach(function (group) {
var groupResult = searchResults[group];
var $groupHtml = me.buildGroupResult(groupResult.key, groupResult.title, groupResult.items);
$resultContainer.append($groupHtml);
});
me.storage.hasResults = true;
me.showResults();
me.showOverlay();
},
/**
* @param {string} groupKey
* @param {string} groupTitle
* @param {array} items
*
* @return {jQuery}
*/
buildGroupResult: function (groupKey, groupTitle, items) {
if (items.length === 0) {
return;
}
if (typeof groupTitle === 'undefined') {
groupTitle = 'Ergebnis';
}
var $resultWrapper = $('<div class="result-group">');
var $resultList = $('<ul class="result-list">');
var $listHead = $('<li class="result-head">').html(groupTitle);
$resultList.append($listHead);
items.forEach(function (item) {
item.group = groupKey;
var itemType = item.type !== null ? item.type : 'default';
var $listItem;
switch (itemType) {
case 'default':
default:
$listItem = me.buildDefaultItemResult(item);
break;
}
$resultList.append($listItem);
});
$resultWrapper.append($resultList);
return $resultWrapper;
},
/**
* @param {object} item
*
* @return {jQuery}
*/
buildDefaultItemResult: function (item) {
var hasSubtitle = item.hasOwnProperty('subtitle') && typeof item.subtitle === 'string';
var hasAdditionalInfos =
item.hasOwnProperty('additionalInfos') &&
typeof item.additionalInfos === 'object' &&
item.additionalInfos !== null;
var mainTitle = '<span class="title-main">' + item.title + '</span>';
var subTitle = hasSubtitle ? '<span class="title-sub">' + item.subtitle + '</span>' : '';
var titleString = '<span class="title">' + mainTitle + subTitle + '</span>';
if (hasAdditionalInfos) {
titleString += '<span class="caption">';
$.each(item.additionalInfos, function (index, additionalInfo) {
titleString += '<span class="additional">' + additionalInfo + '</span>';
});
titleString += '</span>';
}
var $listItem = $('<li>').addClass('result-item');
var $itemLink = $('<a>').attr('href', item.link).html(titleString);
$itemLink.appendTo($listItem);
$itemLink.on('click', function (e) {
e.preventDefault();
me.renderItemDetails(item);
});
return $listItem;
},
/**
* Rendert Ergebnisdetails
*
* @param {object} item
*/
renderItemDetails: function (item) {
// Per AJAX ausführliche Inhalte nachladen
me.fetchItemDetailsDynamicContent(item).then(
function (data) {
me.renderItemDetailsDynamicContent(data, item);
},
function (jqXhr) {
var error =
typeof jqXhr.responseJSON !== 'undefined' &&
typeof jqXhr.responseJSON.error !== 'undefined'
? jqXhr.responseJSON.error
: 'Unbekannter Fehler';
alert('Fehler beim Laden der Detail-Informationen: ' + error);
}
);
},
/**
* @param {object} detailResult
* @param {object} listItem Originales Item-Objekt aus Suchergebnis-Liste
*
* @return {void}
*/
renderItemDetailsDynamicContent: function (detailResult, listItem) {
if (!detailResult.hasOwnProperty('data') || detailResult.data === false) {
// Es wurde kein Detail-Result gefunden
// Link aus Suchergebnis-Item aufrufen
me.hideDetails();
window.location.href = listItem.link;
return;
}
var detail = detailResult.data;
var $details = me.storage.$details;
// Überschrift rendern
var $headline = $('<h1>').html(detail.title);
$details.html('').append($headline);
// Attachments (z.B. Buttons) rendern
if (detail.hasOwnProperty('attachments')) {
var $attachments = me.generateDetailAttachments(detail.attachments);
$details.append($attachments);
}
me.showDetails();
},
/**
* @param {object} item
*
* @return {jqXHR}
*/
fetchItemDetailsDynamicContent: function (item) {
return $.ajax({
url: 'index.php?module=supersearch&action=ajax&cmd=detail',
method: 'post',
dataType: 'json',
data: {
detail_group: item.group,
detail_identifier: item.identifier
},
error: function (jqXHR, textStatus, errorThrown) {
var errorMessage = 'SuperSearch - Unbekannter Fehler #32: ' + errorThrown;
// PHP-Skript hat Fehler geliefert (z.b. 404)
if (textStatus === 'error') {
errorMessage = 'SuperSearch - Unbekannter Server-Fehler beim Laden des Detail-Ergebnisses: ';
errorMessage += errorThrown;
}
// PHP-Skript liefer JSON-Error-Response
if (jqXHR.hasOwnProperty('responseJSON') && jqXHR.responseJSON.hasOwnProperty('error')) {
errorMessage = 'SuperSearch - Server-Fehler beim Laden des Detail-Ergebnisses: ';
errorMessage += jqXHR.responseJSON.error;
}
alert(errorMessage);
}
});
},
/**
* @param {Array} attachments
*
* @return {jQuery} jQuery-Element
*/
generateDetailAttachments: function (attachments) {
var $attachments = $('<div>');
$.each(attachments, function (index, attachment) {
if (!attachment.hasOwnProperty('type')) {
console.error('Attachment ungültig. "type"-Property fehlt.');
return;
}
if (!attachment.hasOwnProperty('data')) {
console.error('Attachment ungültig. "data"-Property fehlt.');
return;
}
if (attachment.type === 'button_block') {
var $buttonBlock = me.generateDetailAttachmentTypeButtonBlock(attachment.data);
$attachments.append($buttonBlock);
}
if (attachment.type === 'content_static') {
var $contentStatic = me.generateDetailAttachmentTypeStaticContent(attachment.data);
$attachments.append($contentStatic);
}
if (attachment.type === 'content_dynamic') {
var $contentDynamic = me.generateDetailAttachmentTypeDynamicContent(attachment.data);
$attachments.append($contentDynamic);
}
});
return $attachments;
},
/**
* @param {Array} items
*
* @return {jQuery} jQuery-Element
*/
generateDetailAttachmentTypeButtonBlock: function (items) {
var $buttonBlock = $('<div>');
$.each(items, function (index, item) {
var $button = $('<a>').text(item.title).addClass('button');
if (item.hasOwnProperty('attributes')) {
// Button-Attribute verarbeiten
$.each(item.attributes, function (attrName, attrValue) {
if (attrName === 'class') {
$button.addClass(attrValue);
return;
}
if (attrName === 'data-icon') {
var iconUrl = '';
switch (attrValue) {
case 'help':
iconUrl = './themes/new/images/help.svg';
break;
case 'settings':
iconUrl = './themes/new/images/settings.svg';
break;
}
if (iconUrl !== '') {
$button.addClass('icon');
$button.addClass('icon-' + attrValue);
var $iconElem = $('<img alt="Handbuch">').attr('src', iconUrl);
var $iconWrapper = $('<span class="icon">').append($iconElem);
$button.prepend($iconWrapper);
}
}
$button.attr(attrName, attrValue);
});
}
$button.appendTo($buttonBlock);
});
return $buttonBlock;
},
/**
* @param {Object} data
*
* @return {jQuery} jQuery-Element
*/
generateDetailAttachmentTypeStaticContent: function (data) {
return $('<p>').html(data.content);
},
/**
* @param {Object} data
*
* @return {jQuery} jQuery-Element
*/
generateDetailAttachmentTypeDynamicContent: function (data) {
var $dynamicContent = $('<div>').addClass('minidetail');
if (data.hasOwnProperty('url') && data.url !== null) {
me.fetchMiniDetailContent(data.url, data.params)
.then(
function (htmlContent) {
$dynamicContent.html(htmlContent);
me.storage.$details.append($dynamicContent);
},
function (jqXhr) {
var message = 'Fehler beim Laden der Mini-Details: ';
if (jqXhr.hasOwnProperty('responseJSON') && jqXhr.responseJSON.hasOwnProperty('error')) {
message += jqXhr.responseJSON.error;
} else {
message += jqXhr.status + ' ' + jqXhr.statusText;
}
$('<div class="error"></div>').text(message).appendTo(me.storage.$details);
}
);
}
return $dynamicContent;
},
/**
* @param {string} miniDetailUrl
* @param {Object} miniDetailParams Zusätzliche POST-Parameter
*
* @return {jqXHR}
*/
fetchMiniDetailContent: function (miniDetailUrl, miniDetailParams) {
if (miniDetailUrl.substr(0, 10) !== 'index.php?') {
alert('Mini-Detail-URL ist ungültig: ' + miniDetailUrl);
throw 'Mini-Detail-URL ist ungültig: ' + miniDetailUrl;
}
if (typeof miniDetailParams !== 'object') {
miniDetailParams = {};
}
return $.ajax({
url: miniDetailUrl,
data: miniDetailParams,
method: 'post',
dataType: 'html'
});
},
/**
* Suchergebnisse einblenden
*/
showResults: function () {
me.getOverlay().addClass('has-result');
me.getOverlay().find('section.empty-message').hide();
me.getOverlay().find('section.error-message').hide();
},
/**
* Suchergebnisse ausblenden
*/
hideResults: function () {
me.getOverlay().removeClass('has-result');
me.getOverlay().find('section.empty-message').hide();
me.getOverlay().find('section.error-message').hide();
me.getOverlay().find('section.last-update').hide();
},
/**
* Details einblenden
*/
showDetails: function () {
me.getOverlay().addClass('has-detail');
me.getOverlay().find('.detail-wrapper').scrollTop(0);
},
/**
* Details einblenden
*/
hideDetails: function () {
me.getOverlay().removeClass('has-detail');
},
/**
* Hinweis anzeigen das keine Ergebnisse gefunden wurden
*/
showEmptyResults: function () {
me.showOverlay();
me.hideDetails();
me.getOverlay().removeClass('has-result');
me.getOverlay().find('section.empty-message').show();
me.getOverlay().find('section.error-message').hide();
},
/**
* @param {string} errorMessage
*/
showErrorMessage: function (errorMessage) {
me.showOverlay();
me.hideDetails();
me.getOverlay().find('section.empty-message').hide();
me.getOverlay().find('section.error-message').html(errorMessage).show();
},
/**
* Puffer-Funktion um Events erst nach einer bestimmten Zeit auszuführen
*
* @param {function} callback
* @param {number} delay
* @param {object|null} contextParam
*/
debounce: function (callback, delay, contextParam) {
var context = typeof contextParam !== 'undefined' && contextParam !== null ? contextParam : this;
var args = arguments;
window.clearTimeout(me.storage.debounceBuffer);
me.storage.debounceBuffer = window.setTimeout(function () {
callback.apply(context, args);
}, delay || 250);
}
};
return {
init: me.init
};
})(jQuery);
$(function () {
SuperSearch.init();
});