From bb563c26cc5eb39df29a10ed300f82c1f7bbcc82 Mon Sep 17 00:00:00 2001
From: Craig Heydenburg <craigh@mac.com>
Date: Tue, 21 Jan 2020 17:10:27 -0500
Subject: [PATCH] initial commit

---
 .gitignore                    |   2 +
 Column.php                    | 150 ++++++++++++++++++++++
 README.md                     |   5 +-
 SortableColumns.php           | 231 ++++++++++++++++++++++++++++++++++
 Tests/ColumnTest.php          | 127 +++++++++++++++++++
 Tests/SortableColumnsTest.php | 222 ++++++++++++++++++++++++++++++++
 composer.json                 |  32 +++++
 phpunit.xml.dist              |  28 +++++
 8 files changed, 796 insertions(+), 1 deletion(-)
 create mode 100644 .gitignore
 create mode 100644 Column.php
 create mode 100644 SortableColumns.php
 create mode 100644 Tests/ColumnTest.php
 create mode 100644 Tests/SortableColumnsTest.php
 create mode 100644 composer.json
 create mode 100644 phpunit.xml.dist

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c8153b5
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/composer.lock
+/vendor/
diff --git a/Column.php b/Column.php
new file mode 100644
index 0000000..bf8adf9
--- /dev/null
+++ b/Column.php
@@ -0,0 +1,150 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Zikula package.
+ *
+ * Copyright Zikula Foundation - https://ziku.la/
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Zikula\Component\SortableColumns;
+
+/**
+ * Class Column
+ *
+ * A column defines a column of a data table that is used in conjunction with SortableColumns to
+ * assist in the display of column headers and links to facilitate resorting based on column and direction.
+ */
+class Column
+{
+    public const DIRECTION_ASCENDING = 'ASC';
+
+    public const DIRECTION_DESCENDING = 'DESC';
+
+    public const CSS_CLASS_UNSORTED = 'unsorted';
+
+    public const CSS_CLASS_ASCENDING = 'sorted-asc';
+
+    public const CSS_CLASS_DESCENDING = 'sorted-desc';
+
+    /**
+     * @var string
+     */
+    private $name;
+
+    /**
+     * @var string
+     */
+    private $defaultSortDirection;
+
+    /**
+     * @var string
+     */
+    private $currentSortDirection;
+
+    /**
+     * @var string
+     */
+    private $reverseSortDirection;
+
+    /**
+     * @var string
+     */
+    private $cssClassString;
+
+    /**
+     * @var bool
+     */
+    private $isSortColumn = false;
+
+    public function __construct(string $name, string $currentSortDirection = null, string $defaultSortDirection = null)
+    {
+        $this->name = $name;
+        $this->currentSortDirection = !empty($currentSortDirection) ? $currentSortDirection : self::DIRECTION_ASCENDING;
+        $this->reverseSortDirection = $this->reverse($this->currentSortDirection);
+        $this->defaultSortDirection = !empty($defaultSortDirection) ? $defaultSortDirection : self::DIRECTION_ASCENDING;
+        $this->cssClassString = self::CSS_CLASS_UNSORTED;
+    }
+
+    public function getName(): string
+    {
+        return $this->name;
+    }
+
+    public function setName(string $name): void
+    {
+        $this->name = $name;
+    }
+
+    public function getDefaultSortDirection(): string
+    {
+        return $this->defaultSortDirection;
+    }
+
+    public function setDefaultSortDirection(string $defaultSortDirection): void
+    {
+        $this->defaultSortDirection = $defaultSortDirection;
+    }
+
+    public function getCurrentSortDirection(): string
+    {
+        return $this->currentSortDirection;
+    }
+
+    public function setCurrentSortDirection(string $currentSortDirection): void
+    {
+        $this->currentSortDirection = $currentSortDirection;
+        $this->setCssClassString($this->cssFromDirection($currentSortDirection));
+        $this->reverseSortDirection = $this->reverse($currentSortDirection);
+    }
+
+    public function getReverseSortDirection(): string
+    {
+        return $this->reverseSortDirection;
+    }
+
+    public function setReverseSortDirection(string $reverseSortDirection): void
+    {
+        $this->reverseSortDirection = $reverseSortDirection;
+    }
+
+    public function getCssClassString(): string
+    {
+        return $this->cssClassString;
+    }
+
+    public function setCssClassString(string $cssClassString): void
+    {
+        $this->cssClassString = $cssClassString;
+    }
+
+    public function isSortColumn(): bool
+    {
+        return $this->isSortColumn;
+    }
+
+    public function setSortColumn(bool $isSortColumn): void
+    {
+        $this->isSortColumn = $isSortColumn;
+    }
+
+    /**
+     * Reverse the direction constants.
+     */
+    private function reverse(string $direction): string
+    {
+        return (self::DIRECTION_ASCENDING === $direction) ? self::DIRECTION_DESCENDING : self::DIRECTION_ASCENDING;
+    }
+
+    /**
+     * Determine a css class based on the direction.
+     */
+    private function cssFromDirection(string $direction): string
+    {
+        return (self::DIRECTION_ASCENDING === $direction) ? self::CSS_CLASS_ASCENDING : self::CSS_CLASS_DESCENDING;
+    }
+}
diff --git a/README.md b/README.md
index bd620c4..4be7f66 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,5 @@
 # SortableColumns
-SortableColumns is a zikula component to help manage data table column headings that can be clicked to sort the data
+SortableColumns is a Zikula component to help manage data table column headings that can be clicked to sort the data.
+The collection is an `Doctrine\Common\Collections\ArrayCollection` of `Zikula\Component\SortableColumns\Column` objects.
+Use `SortableColumns::generateSortableColumns` to create an array of attributes (url, css class) indexed by column name
+which can be used in the generation of table headings/links.
diff --git a/SortableColumns.php b/SortableColumns.php
new file mode 100644
index 0000000..99617bc
--- /dev/null
+++ b/SortableColumns.php
@@ -0,0 +1,231 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Zikula package.
+ *
+ * Copyright Zikula Foundation - https://ziku.la/
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Zikula\Component\SortableColumns;
+
+use Doctrine\Common\Collections\ArrayCollection;
+use InvalidArgumentException;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\Routing\RouterInterface;
+
+/**
+ * Class SortableColumns
+ *
+ * SortableColumns is a zikula component to help manage data table column headings that can be clicked to sort the data.
+ * The collection is an ArrayCollection of Zikula\Component\SortableColumns\Column objects.
+ * Use the ::generateSortableColumns method to create an array of attributes (url, css class) indexed by column name
+ * which can be used in the generation of table headings/links.
+ */
+class SortableColumns
+{
+    /**
+     * @var RouterInterface
+     */
+    private $router;
+
+    /**
+     * The route name string to generate urls for column headers
+     * @var string
+     */
+    private $routeName;
+
+    /**
+     * A collection of Columns to manage
+     * @var ArrayCollection
+     */
+    private $columnCollection;
+
+    /**
+     * The default column (if unset, the first column add is used)
+     * @var Column
+     */
+    private $defaultColumn;
+
+    /**
+     * The column used to sort the data
+     * @var Column
+     */
+    private $sortColumn;
+
+    /**
+     * The direction to sorted (constant from Column class)
+     * @var string
+     */
+    private $sortDirection = Column::DIRECTION_ASCENDING;
+
+    /**
+     * The name of the html field that holds the selected orderBy field (default: `sort-field`)
+     * @var string
+     */
+    private $sortFieldName;
+
+    /**
+     * The name of the html field that holds the selected orderBy direction (default: `sort-direction`)
+     * @var string
+     */
+    private $directionFieldName;
+
+    /**
+     * Additional url parameters that must be included in the generated urls
+     * @var array
+     */
+    private $additionalUrlParameters = [];
+
+    public function __construct(
+        RouterInterface $router,
+        string $routeName,
+        string $sortFieldName = 'sort-field',
+        string $directionFieldName = 'sort-direction'
+    ) {
+        $this->router = $router;
+        $this->routeName = $routeName;
+        $this->sortFieldName = $sortFieldName;
+        $this->directionFieldName = $directionFieldName;
+        $this->columnCollection = new ArrayCollection();
+    }
+
+    /**
+     * Create an array of column definitions indexed by column name
+     * <code>
+     *   ['a' =>
+     *     ['url' => '/foo?sort-direction=ASC&sort-field=a',
+     *      'class' => 'z-order-unsorted'
+     *     ],
+     *   ]
+     * </code>
+     */
+    public function generateSortableColumns(): array
+    {
+        $resultArray = [];
+        /** @var Column $column */
+        foreach ($this->columnCollection as $column) {
+            $this->additionalUrlParameters[$this->directionFieldName] = $column->isSortColumn() ? $column->getReverseSortDirection() : $column->getCurrentSortDirection();
+            $this->additionalUrlParameters[$this->sortFieldName] = $column->getName();
+            $resultArray[$column->getName()] = [
+                'url' => $this->router->generate($this->routeName, $this->additionalUrlParameters),
+                'class' => $column->getCssClassString(),
+            ];
+        }
+
+        return $resultArray;
+    }
+
+    /**
+     * Add one column.
+     */
+    public function addColumn(Column $column): void
+    {
+        $this->columnCollection->set($column->getName(), $column);
+    }
+
+    /**
+     * Shortcut to add an array of columns.
+     */
+    public function addColumns(array $columns = []): void
+    {
+        foreach ($columns as $column) {
+            if ($column instanceof Column) {
+                $this->addColumn($column);
+            } else {
+                throw new InvalidArgumentException('Columns must be an instance of \Zikula\Component\SortableColumns\Column.');
+            }
+        }
+    }
+
+    public function removeColumn(string $name): void
+    {
+        $this->columnCollection->remove($name);
+    }
+
+    public function getColumn(?string $name): ?Column
+    {
+        return $this->columnCollection->get($name);
+    }
+
+    /**
+     * Set the column to sort by and the sort direction.
+     */
+    public function setOrderBy(Column $sortColumn = null, string $sortDirection = null): void
+    {
+        $sortColumn = $sortColumn ?: $this->getDefaultColumn();
+        if (null === $sortColumn) {
+            return;
+        }
+        $sortDirection = $sortDirection ?: Column::DIRECTION_ASCENDING;
+        $this->setSortDirection($sortDirection);
+        $this->setSortColumn($sortColumn);
+    }
+
+    /**
+     * Shortcut to set OrderBy using the Request object.
+     */
+    public function setOrderByFromRequest(Request $request): void
+    {
+        if (null === $this->getDefaultColumn()) {
+            return;
+        }
+        $sortColumnName = $request->get($this->sortFieldName, $this->getDefaultColumn()->getName());
+        $sortDirection = $request->get($this->directionFieldName, Column::DIRECTION_ASCENDING);
+        $this->setOrderBy($this->getColumn($sortColumnName), $sortDirection);
+    }
+
+    public function getSortColumn(): ?Column
+    {
+        return $this->sortColumn ?? $this->getDefaultColumn();
+    }
+
+    private function setSortColumn(Column $sortColumn): void
+    {
+        if ($this->columnCollection->contains($sortColumn)) {
+            $this->sortColumn = $sortColumn;
+            $sortColumn->setSortColumn(true);
+            $sortColumn->setCurrentSortDirection($this->getSortDirection());
+        }
+    }
+
+    public function getSortDirection(): string
+    {
+        return $this->sortDirection;
+    }
+
+    private function setSortDirection(string $sortDirection): void
+    {
+        if (in_array($sortDirection, [Column::DIRECTION_ASCENDING, Column::DIRECTION_DESCENDING], true)) {
+            $this->sortDirection = $sortDirection;
+        }
+    }
+
+    public function getDefaultColumn(): ?Column
+    {
+        if (!empty($this->defaultColumn)) {
+            return $this->defaultColumn;
+        }
+
+        return $this->columnCollection->first();
+    }
+
+    public function setDefaultColumn(Column $defaultColumn): void
+    {
+        $this->defaultColumn = $defaultColumn;
+    }
+
+    public function getAdditionalUrlParameters(): array
+    {
+        return $this->additionalUrlParameters;
+    }
+
+    public function setAdditionalUrlParameters(array $additionalUrlParameters = []): void
+    {
+        $this->additionalUrlParameters = $additionalUrlParameters;
+    }
+}
diff --git a/Tests/ColumnTest.php b/Tests/ColumnTest.php
new file mode 100644
index 0000000..88f19d6
--- /dev/null
+++ b/Tests/ColumnTest.php
@@ -0,0 +1,127 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Zikula package.
+ *
+ * Copyright Zikula Foundation - https://ziku.la/
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Zikula\Component\SortableColumns\Tests;
+
+use PHPUnit\Framework\TestCase;
+use Zikula\Component\SortableColumns\Column;
+
+class ColumnTest extends TestCase
+{
+    /**
+     * @var Column
+     */
+    private $column;
+
+    protected function setup(): void
+    {
+        $this->column = new Column('foo');
+    }
+
+    /**
+     * @covers Column::getName
+     */
+    public function testGetName(): void
+    {
+        $this->assertEquals('foo', $this->column->getName());
+    }
+
+    /**
+     * @covers Column::setName
+     */
+    public function testSetName(): void
+    {
+        $this->column->setName('bar');
+        $this->assertEquals('bar', $this->column->getName());
+    }
+
+    /**
+     * @covers Column::getDefaultSortDirection
+     */
+    public function testGetDefaultSortDirection(): void
+    {
+        $this->assertEquals(Column::DIRECTION_ASCENDING, $this->column->getDefaultSortDirection());
+    }
+
+    /**
+     * @covers Column::setDefaultSortDirection
+     */
+    public function getSetDefaultSortDirection(): void
+    {
+        $this->column->setDefaultSortDirection(Column::DIRECTION_DESCENDING);
+        $this->assertEquals(Column::DIRECTION_DESCENDING, $this->column->getDefaultSortDirection());
+    }
+
+    /**
+     * @covers Column::getCurrentSortDirection
+     */
+    public function testGetCurrentSortDirection(): void
+    {
+        $this->assertEquals(Column::DIRECTION_ASCENDING, $this->column->getCurrentSortDirection());
+    }
+
+    /**
+     * @covers Column::setCurrentSortDirection
+     */
+    public function testSetCurrentSortDirection(): void
+    {
+        $this->column->setCurrentSortDirection(Column::DIRECTION_DESCENDING);
+        $this->assertEquals(Column::DIRECTION_DESCENDING, $this->column->getCurrentSortDirection());
+        $this->assertEquals(Column::DIRECTION_ASCENDING, $this->column->getReverseSortDirection());
+        $this->assertEquals(Column::CSS_CLASS_DESCENDING, $this->column->getCssClassString());
+    }
+
+    /**
+     * @covers Column::getReverseSortDirection
+     */
+    public function testGetReverseSortDirection(): void
+    {
+        $this->assertEquals(Column::DIRECTION_DESCENDING, $this->column->getReverseSortDirection());
+    }
+
+    /**
+     * @covers Column::getCssClassString
+     */
+    public function testGetCssClassString(): void
+    {
+        $this->assertEquals(Column::CSS_CLASS_UNSORTED, $this->column->getCssClassString());
+    }
+
+    /**
+     * @covers Column::setCssClassString
+     */
+    public function testSetCssClassString(): void
+    {
+        $this->column->setCssClassString(Column::CSS_CLASS_ASCENDING);
+        $this->assertEquals(Column::CSS_CLASS_ASCENDING, $this->column->getCssClassString());
+        $this->column->setCssClassString(Column::CSS_CLASS_DESCENDING);
+        $this->assertEquals(Column::CSS_CLASS_DESCENDING, $this->column->getCssClassString());
+    }
+
+    /**
+     * @covers Column::isSortColumn
+     */
+    public function testIsSortColumn(): void
+    {
+        $this->assertFalse($this->column->isSortColumn());
+    }
+
+    /**
+     * @covers Column::setSortColumn
+     */
+    public function testSetSortColumn(): void
+    {
+        $this->column->setSortColumn(true);
+        $this->assertTrue($this->column->isSortColumn());
+    }
+}
diff --git a/Tests/SortableColumnsTest.php b/Tests/SortableColumnsTest.php
new file mode 100644
index 0000000..837af5c
--- /dev/null
+++ b/Tests/SortableColumnsTest.php
@@ -0,0 +1,222 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of the Zikula package.
+ *
+ * Copyright Zikula Foundation - https://ziku.la/
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Zikula\Component\SortableColumns\Tests;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\Routing\RouterInterface;
+use Zikula\Component\SortableColumns\Column;
+use Zikula\Component\SortableColumns\SortableColumns;
+
+class SortableColumnsTest extends TestCase
+{
+    /**
+     * @var SortableColumns
+     */
+    private $sortableColumns;
+
+    protected function setUp(): void
+    {
+        $router = $this
+            ->getMockBuilder(RouterInterface::class)
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        $router
+            ->method('generate')
+            ->willReturnCallback(static function ($id, $params) {
+                return '/foo?' . http_build_query($params);
+            });
+
+        $this->sortableColumns = new SortableColumns($router, 'foo');
+        $this->sortableColumns->addColumn(new Column('a'));
+        $this->sortableColumns->addColumn(new Column('b'));
+        $this->sortableColumns->addColumn(new Column('c'));
+    }
+
+    /**
+     * @covers SortableColumns::getColumn
+     */
+    public function testGetColumn(): void
+    {
+        $a = $this->sortableColumns->getColumn('a');
+        $this->assertInstanceOf(Column::class, $a);
+        $this->assertEquals('a', $a->getName());
+    }
+
+    /**
+     * @covers SortableColumns::addColumn
+     */
+    public function testAddColumn(): void
+    {
+        $d = new Column('d');
+        $this->sortableColumns->addColumn($d);
+        $this->assertEquals($d, $this->sortableColumns->getColumn('d'));
+    }
+
+    /**
+     * @covers SortableColumns::addColumns
+     */
+    public function testAddColumns(): void
+    {
+        $e = new Column('e');
+        $f = new Column('f');
+        $g = new Column('g');
+        $this->sortableColumns->addColumns([$e, $f, $g]);
+        $this->assertEquals($e, $this->sortableColumns->getColumn('e'));
+        $this->assertEquals($f, $this->sortableColumns->getColumn('f'));
+        $this->assertEquals($g, $this->sortableColumns->getColumn('g'));
+    }
+
+    /**
+     * @covers SortableColumns::getDefaultColumn
+     */
+    public function testGetDefaultColumn(): void
+    {
+        $a = $this->sortableColumns->getColumn('a');
+        $b = $this->sortableColumns->getColumn('b');
+        $this->assertEquals($a, $this->sortableColumns->getDefaultColumn());
+        $this->assertNotEquals($b, $this->sortableColumns->getDefaultColumn());
+    }
+
+    /**
+     * @covers SortableColumns::removeColumn
+     */
+    public function testRemoveColumn(): void
+    {
+        $this->sortableColumns->removeColumn('b');
+        $this->assertNull($this->sortableColumns->getColumn('b'));
+    }
+
+    /**
+     * @covers SortableColumns::getSortDirection
+     */
+    public function testGetSortDirection(): void
+    {
+        $this->assertEquals(Column::DIRECTION_ASCENDING, $this->sortableColumns->getSortDirection());
+    }
+
+    /**
+     * @covers SortableColumns::getSortColumn
+     */
+    public function testGetSortColumn(): void
+    {
+        $a = $this->sortableColumns->getColumn('a');
+        $this->assertEquals($a, $this->sortableColumns->getSortColumn());
+    }
+
+    /**
+     * @covers SortableColumns::setOrderBy
+     */
+    public function testSetOrderBy(): void
+    {
+        $c = $this->sortableColumns->getColumn('c');
+        $this->sortableColumns->setOrderBy($c, Column::DIRECTION_DESCENDING);
+        $this->assertEquals(Column::DIRECTION_DESCENDING, $this->sortableColumns->getSortDirection());
+        $this->assertEquals($c, $this->sortableColumns->getSortColumn());
+    }
+
+    /**
+     * @covers SortableColumns::setOrderByFromRequest
+     */
+    public function testSetOrderByFromRequest(): void
+    {
+        $request = new Request([
+            'sort-field' => 'b',
+            'sort-direction' => 'DESC'
+        ]);
+        $this->sortableColumns->setOrderByFromRequest($request);
+        $b = $this->sortableColumns->getColumn('b');
+        $this->assertEquals($b, $this->sortableColumns->getSortColumn());
+        $this->assertEquals(Column::DIRECTION_DESCENDING, $this->sortableColumns->getSortDirection());
+    }
+
+    /**
+     * @covers SortableColumns::setAdditionalUrlParameters
+     * @covers SortableColumns::getAdditionalUrlParameters
+     */
+    public function testAdditionalUrlParameters(): void
+    {
+        $this->sortableColumns->setAdditionalUrlParameters(['x' => 1, 'z' => 0]);
+        $this->assertEquals(['x' => 1, 'z' => 0], $this->sortableColumns->getAdditionalUrlParameters());
+    }
+
+    /**
+     * @dataProvider columnDefProvider
+     * @covers SortableColumns::generateSortableColumns
+     */
+    public function testGenerateSortableColumns(?string $col, string $dir, array $columnDef = []): void
+    {
+        $this->sortableColumns->setOrderBy($this->sortableColumns->getColumn($col), $dir);
+        $this->assertEquals($columnDef, $this->sortableColumns->generateSortableColumns());
+    }
+
+    /**
+     * @covers SortableColumns::generateSortableColumns
+     * @covers SortableColumns::setAdditionalUrlParameters
+     */
+    public function testGenerateSortableColumnsWithAdditionalUrlParameters(): void
+    {
+        $expected = [
+            'a' => ['url' => '/foo?x=1&z=0&sort-direction=' . Column::DIRECTION_DESCENDING . '&sort-field=a', 'class' => Column::CSS_CLASS_ASCENDING],
+            'b' => ['url' => '/foo?x=1&z=0&sort-direction=' . Column::DIRECTION_ASCENDING . '&sort-field=b', 'class' => Column::CSS_CLASS_UNSORTED],
+            'c' => ['url' => '/foo?x=1&z=0&sort-direction=' . Column::DIRECTION_ASCENDING . '&sort-field=c', 'class' => Column::CSS_CLASS_UNSORTED],
+        ];
+
+        $this->sortableColumns->setOrderBy($this->sortableColumns->getColumn('a'), Column::DIRECTION_ASCENDING);
+        $this->sortableColumns->setAdditionalUrlParameters(['x' => 1, 'z' => 0]);
+        $this->assertEquals($expected, $this->sortableColumns->generateSortableColumns());
+    }
+
+    public function columnDefProvider(): array
+    {
+        return [
+            [null, '',
+                [
+                    'a' => ['url' => '/foo?sort-direction=' . Column::DIRECTION_DESCENDING . '&sort-field=a', 'class' => Column::CSS_CLASS_ASCENDING],
+                    'b' => ['url' => '/foo?sort-direction=' . Column::DIRECTION_ASCENDING . '&sort-field=b', 'class' => Column::CSS_CLASS_UNSORTED],
+                    'c' => ['url' => '/foo?sort-direction=' . Column::DIRECTION_ASCENDING . '&sort-field=c', 'class' => Column::CSS_CLASS_UNSORTED],
+                ]
+            ],
+            ['a', Column::DIRECTION_ASCENDING,
+                [
+                    'a' => ['url' => '/foo?sort-direction=' . Column::DIRECTION_DESCENDING . '&sort-field=a', 'class' => Column::CSS_CLASS_ASCENDING],
+                    'b' => ['url' => '/foo?sort-direction=' . Column::DIRECTION_ASCENDING . '&sort-field=b', 'class' => Column::CSS_CLASS_UNSORTED],
+                    'c' => ['url' => '/foo?sort-direction=' . Column::DIRECTION_ASCENDING . '&sort-field=c', 'class' => Column::CSS_CLASS_UNSORTED],
+                ]
+            ],
+            ['a', Column::DIRECTION_DESCENDING,
+                [
+                    'a' => ['url' => '/foo?sort-direction=' . Column::DIRECTION_ASCENDING . '&sort-field=a', 'class' => Column::CSS_CLASS_DESCENDING],
+                    'b' => ['url' => '/foo?sort-direction=' . Column::DIRECTION_ASCENDING . '&sort-field=b', 'class' => Column::CSS_CLASS_UNSORTED],
+                    'c' => ['url' => '/foo?sort-direction=' . Column::DIRECTION_ASCENDING . '&sort-field=c', 'class' => Column::CSS_CLASS_UNSORTED],
+                ]
+            ],
+            ['c', Column::DIRECTION_ASCENDING,
+                [
+                    'a' => ['url' => '/foo?sort-direction=' . Column::DIRECTION_ASCENDING . '&sort-field=a', 'class' => Column::CSS_CLASS_UNSORTED],
+                    'b' => ['url' => '/foo?sort-direction=' . Column::DIRECTION_ASCENDING . '&sort-field=b', 'class' => Column::CSS_CLASS_UNSORTED],
+                    'c' => ['url' => '/foo?sort-direction=' . Column::DIRECTION_DESCENDING . '&sort-field=c', 'class' => Column::CSS_CLASS_ASCENDING],
+                ]
+            ],
+            ['b', Column::DIRECTION_DESCENDING,
+                [
+                    'a' => ['url' => '/foo?sort-direction=' . Column::DIRECTION_ASCENDING . '&sort-field=a', 'class' => Column::CSS_CLASS_UNSORTED],
+                    'b' => ['url' => '/foo?sort-direction=' . Column::DIRECTION_ASCENDING . '&sort-field=b', 'class' => Column::CSS_CLASS_DESCENDING],
+                    'c' => ['url' => '/foo?sort-direction=' . Column::DIRECTION_ASCENDING . '&sort-field=c', 'class' => Column::CSS_CLASS_UNSORTED],
+                ]
+            ],
+        ];
+    }
+}
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..3e1a013
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,32 @@
+{
+    "name": "zikula/sortable-columns",
+    "description": "SortableColumns is a zikula component to help manage data table column headings that can be clicked to sort the data",
+    "keywords": ["symfony", "columns", "sortable"],
+    "type": "library",
+    "homepage": "https://ziku.la",
+    "license": "MIT",
+    "authors": [
+        {
+            "name": "Craig Heydenburg",
+            "email": "info@ziku.la"
+        }
+    ],
+    "autoload": {
+        "psr-4": { "Zikula\\Component\\SortableColumns\\": "" }
+    },
+    "autoload-dev": {
+        "psr-4" : {
+            "Zikula\\Component\\SortableColumns\\Tests\\" : "tests/"
+        }
+    },
+    "require": {
+        "php": ">=7.2.0",
+        "symfony/routing": "4.*|5.*",
+        "symfony/http-foundation": "4.*|5.*",
+        "doctrine/common": "2.*"
+    },
+    "require-dev": {
+        "phpunit/phpunit": "^8.4",
+        "symfony/phpunit-bridge": "^4.4|^5.0"
+    }
+}
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 0000000..0a9ad74
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<phpunit backupGlobals="false"
+         backupStaticAttributes="false"
+         colors="true"
+         convertErrorsToExceptions="true"
+         convertNoticesToExceptions="true"
+         convertWarningsToExceptions="true"
+         processIsolation="false"
+         stopOnFailure="false"
+         bootstrap="vendor/autoload.php"
+        >
+    <testsuites>
+        <testsuite name="Zikula SortableColumns Component Test Suite">
+            <directory>./Tests/</directory>
+        </testsuite>
+    </testsuites>
+
+    <filter>
+        <whitelist>
+            <directory>./</directory>
+            <exclude>
+                <directory>./Resources</directory>
+                <directory>./Tests</directory>
+            </exclude>
+        </whitelist>
+    </filter>
+</phpunit>