diff --git a/demos/_includes/Session.php b/demos/_includes/Session.php index 8fa9ad2605..09fcb06049 100644 --- a/demos/_includes/Session.php +++ b/demos/_includes/Session.php @@ -4,11 +4,19 @@ namespace Atk4\Ui\Demos; +use Atk4\Core\AppScopeTrait; use Atk4\Core\NameTrait; -use Atk4\Core\SessionTrait; +use Atk4\Ui\App; +use Atk4\Ui\SessionTrait; class Session { + use AppScopeTrait; use NameTrait; use SessionTrait; + + public function __construct(App $app) + { + $this->setApp($app); + } } diff --git a/demos/interactive/modal.php b/demos/interactive/modal.php index 586072356f..5bcff81a8d 100644 --- a/demos/interactive/modal.php +++ b/demos/interactive/modal.php @@ -11,7 +11,7 @@ \Atk4\Ui\Header::addTo($app, ['Modal View']); -$session = new Session(); +$session = new Session($app); // Re-usable component implementing counter \Atk4\Ui\Header::addTo($app, ['Static Modal Dialog']); diff --git a/demos/interactive/popup.php b/demos/interactive/popup.php index e17f94e48e..5036e50082 100644 --- a/demos/interactive/popup.php +++ b/demos/interactive/popup.php @@ -20,7 +20,8 @@ /** @var \Atk4\Ui\Lister $cartClass */ $cartClass = AnonymousClassNameCache::get_class(fn () => new class() extends \Atk4\Ui\Lister { - use \Atk4\Core\SessionTrait; + use \Atk4\Ui\SessionTrait; + public $items = []; public $defaultTemplate = 'lister.html'; diff --git a/docs/session.rst b/docs/session.rst new file mode 100644 index 0000000000..de5dde6ad7 --- /dev/null +++ b/docs/session.rst @@ -0,0 +1,62 @@ +============= +Session Trait +============= + +.. php:trait:: SessionTrait + + +Introduction +============ + +SessionTrait is a simple way to let object store relevant data in the session. Specifically used in ATK UI +some objects want to memorize data. (see https://github.com/atk4/ui/blob/develop/src/Wizard.php#L12) + +You would need 3 things. First make use of session trait:: + + use \Atk4\Ui\SessionTrait; + +next you may memorize any value, which will be stored independently from any other object (even of a same class):: + + $this->memorize('dsn', $dsn); + +Later when you need the value, you can simply recall it:: + + $dsn = $this->recall('dsn'); + + +Properties +========== + +.. php:attr:: session_key + + Internal property to make sure that all session data will be stored in one + "container" (array key). + +Methods +======= + +.. php:method:: startSession($options = []) + + Create new session. + +.. php:method:: destroySession() + + Destroy existing session. + +.. php:method:: memorize($key, $value) + + Remember data in object-relevant session data. + +.. php:method:: learn($key, $default = null) + + Similar to memorize, but if value for key exist, will return it. + +.. php:method:: recall($key, $default = null) + + Returns session data for this object. If not previously set, then $default + is returned. + +.. php:method:: forget($key = null) + + Forget session data for arg $key. If $key is omitted will forget all + associated session data. diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 8aada406cd..a3f9f2158d 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -211,6 +211,10 @@ parameters: - path: 'src/Panel/Right.php' message: '~^Call to an undefined method Atk4\\Ui\\JsExpression::addPanel\(\)\.$~' + - + path: 'src/SessionTrait.php' + count: 4 + message: '~^Access to an undefined property Atk4\\Ui\\Tests\\SessionAbstractMock::\$name\.$~' - path: 'src/Table/Column.php' message: '~^Call to an undefined method Atk4\\Ui\\AbstractView::setHoverable\(\)\.$~' diff --git a/phpunit.xml.dist b/phpunit.xml.dist index d59efca385..60055a9dde 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -13,6 +13,7 @@ demos_http + require_session diff --git a/src/App.php b/src/App.php index 6665f425d0..dc045afe8a 100644 --- a/src/App.php +++ b/src/App.php @@ -105,6 +105,9 @@ class App /** @var Persistence|Persistence\Sql */ public $db; + /** @var App\Session */ + public $session; + /** @var string[] Extra HTTP headers to send on exit. */ protected $response_headers = [ self::HEADER_STATUS_CODE => '200', @@ -136,39 +139,11 @@ class App public $templateClass = HtmlTemplate::class; - /** - * @param array $defaults - */ - public function __construct($defaults = []) + public function __construct(array $defaults = []) { $this->setApp($this); - // Process defaults - if (is_string($defaults)) { - $defaults = ['title' => $defaults]; - } - - if (isset($defaults[0])) { - $defaults['title'] = $defaults[0]; - unset($defaults[0]); - } - - /* - if (is_array($defaults)) { - throw (new Exception('Constructor requires array argument')) - ->addMoreInfo('arg', $defaults); - }*/ $this->setDefaults($defaults); - /* - - foreach ($defaults as $key => $val) { - if (is_array($val)) { - $this->{$key} = array_merge(is_array($this->{$key} ?? null) ? $this->{$key} : [], $val); - } elseif ($val !== null) { - $this->{$key} = $val; - } - } - */ $this->setupTemplateDirs(); @@ -208,11 +183,14 @@ static function (int $severity, string $msg, string $file, int $line): bool { $this->setupAlwaysRun(); } - // Set up UI persistence if ($this->ui_persistence === null) { $this->ui_persistence = new UiPersistence(); } + if ($this->session === null) { + $this->session = new App\Session(); + } + // setting up default executor factory. $this->executorFactory = Factory::factory([ExecutorFactory::class]); } diff --git a/src/App/Session.php b/src/App/Session.php new file mode 100644 index 0000000000..45f680b606 --- /dev/null +++ b/src/App/Session.php @@ -0,0 +1,121 @@ +startSession(); + + $_SESSION[$this->session_key][$namespace][$key] = $value; + + return $value; + } + + /** + * Similar to memorize, but if value for key exist, will return it. + * + * @param mixed $default + * + * @return mixed Previously memorized data or $default + */ + public function learn(string $namespace, string $key, $default = null) + { + $this->startSession(); + + if (!isset($_SESSION[$this->session_key][$namespace][$key])) { + if ($default instanceof \Closure) { + $default = $default($key); + } + + return $this->memorize($namespace, $key, $default); + } + + return $this->recall($namespace, $key); + } + + /** + * Returns session data for this object. If not previously set, then + * $default is returned. + * + * @param mixed $default + * + * @return mixed Previously memorized data or $default + */ + public function recall(string $namespace, string $key, $default = null) + { + $this->startSession(); + + if (!isset($_SESSION[$this->session_key][$namespace][$key])) { + if ($default instanceof \Closure) { + $default = $default($key); + } + + return $default; + } + + return $_SESSION[$this->session_key][$namespace][$key]; + } + + /** + * Forget session data for $key. If $key is omitted will forget all + * associated session data. + * + * @param string $key Optional key of data to forget + */ + public function forget(string $namespace, string $key = null): void + { + $this->startSession(); + + if ($key === null) { + unset($_SESSION[$this->session_key][$namespace]); + } else { + unset($_SESSION[$this->session_key][$namespace][$key]); + } + } +} diff --git a/src/SessionTrait.php b/src/SessionTrait.php new file mode 100644 index 0000000000..557d18204a --- /dev/null +++ b/src/SessionTrait.php @@ -0,0 +1,72 @@ +getApp()->session; + } + + /** + * Remember data in object-relevant session data. + * + * @param mixed $value + * + * @return mixed $value + */ + public function memorize(string $key, $value) + { + return $this->getSession()->memorize($this->name, $key, $value); + } + + /** + * Similar to memorize, but if value for key exist, will return it. + * + * @param mixed $default + * + * @return mixed Previously memorized data or $default + */ + public function learn(string $key, $default = null) + { + return $this->getSession()->learn($this->name, $key, $default); + } + + /** + * Returns session data for this object. If not previously set, then + * $default is returned. + * + * @param mixed $default + * + * @return mixed Previously memorized data or $default + */ + public function recall(string $key, $default = null) + { + return $this->getSession()->recall($this->name, $key, $default); + } + + /** + * Forget session data for $key. If $key is omitted will forget all + * associated session data. + * + * @param string $key Optional key of data to forget + * + * @return $this + */ + public function forget(string $key = null) + { + $this->getSession()->forget($this->name, $key); + + return $this; + } +} diff --git a/src/Table/Column/FilterModel.php b/src/Table/Column/FilterModel.php index d19256a675..e14749a70d 100644 --- a/src/Table/Column/FilterModel.php +++ b/src/Table/Column/FilterModel.php @@ -4,19 +4,22 @@ namespace Atk4\Ui\Table\Column; +use Atk4\Core\AppScopeTrait; use Atk4\Core\NameTrait; -use Atk4\Core\SessionTrait; use Atk4\Data\Field; use Atk4\Data\Model; use Atk4\Data\Persistence; use Atk4\Data\Types\Types as CustomTypes; +use Atk4\Ui\App; +use Atk4\Ui\SessionTrait; use Doctrine\DBAL\Types\Types; /** * Implement a generic filter model for filtering column data. */ -class FilterModel extends Model +abstract class FilterModel extends Model { + use AppScopeTrait; // needed for SessionTrait use NameTrait; // needed for SessionTrait use SessionTrait; @@ -32,13 +35,20 @@ class FilterModel extends Model /** @var Field The field where this filter need to query data. */ public $lookupField; + public function __construct(App $app, array $defaults = []) + { + $this->setApp($app); + + $persistence = new Persistence\Array_(); + + parent::__construct($persistence, $defaults); + } + /** * Factory method that will return a FilterModel Type class. */ - public static function factoryType(Field $field): self + public static function factoryType(App $app, Field $field): self { - $persistence = new Persistence\Array_(); - $class = [ Types::STRING => FilterModel\TypeString::class, Types::TEXT => FilterModel\TypeString::class, @@ -69,7 +79,9 @@ public static function factoryType(Field $field): self $class = $field->filterModel; } - return new $class($persistence, ['lookupField' => $field]); + $filterModel = new $class($app, ['lookupField' => $field]); + + return $filterModel; } protected function init(): void @@ -118,10 +130,7 @@ public function recallData(): array * * @return Model */ - public function setConditionForModel(Model $model) - { - return $model; - } + abstract public function setConditionForModel(Model $model); /** * Method that will set Field display condition in a form. diff --git a/src/Table/Column/FilterPopup.php b/src/Table/Column/FilterPopup.php index 4229089c36..d58655ceb9 100644 --- a/src/Table/Column/FilterPopup.php +++ b/src/Table/Column/FilterPopup.php @@ -38,10 +38,11 @@ class FilterPopup extends Popup protected function init(): void { parent::init(); + $this->setOption('delay', ['hide' => 1500]); $this->setHoverable(); - $model = FilterModel::factoryType($this->field); + $model = FilterModel::factoryType($this->getApp(), $this->field); $model = $model->createEntity(); $this->form = Form::addTo($this)->addClass(''); diff --git a/src/Wizard.php b/src/Wizard.php index f881436b0f..6bab78d541 100644 --- a/src/Wizard.php +++ b/src/Wizard.php @@ -11,7 +11,7 @@ */ class Wizard extends View { - use \Atk4\Core\SessionTrait; + use SessionTrait; public $defaultTemplate = 'wizard.html'; public $ui = 'steps'; diff --git a/tests/SessionTraitTest.php b/tests/SessionTraitTest.php new file mode 100644 index 0000000000..d3605d3b8c --- /dev/null +++ b/tests/SessionTraitTest.php @@ -0,0 +1,156 @@ +app = new App([ + 'catch_exceptions' => false, + 'always_run' => false, + ]); + + session_abort(); + $sessionDir = sys_get_temp_dir() . '/atk4_test__ui__session'; + if (!file_exists($sessionDir)) { + mkdir($sessionDir); + } + ini_set('session.save_path', $sessionDir); + } + + protected function tearDown(): void + { + session_abort(); + $sessionDir = ini_get('session.save_path'); + foreach (scandir($sessionDir) as $f) { + if (!in_array($f, ['.', '..'], true)) { + unlink($sessionDir . '/' . $f); + } + } + + rmdir($sessionDir); + + parent::tearDown(); + } + + public function testException1(): void + { + // when try to start session without NameTrait + $this->expectException(Exception::class); + $m = new SessionWithoutNameMock($this->app); + $m->memorize('test', 'foo'); + } + + public function testConstructor(): void + { + $m = new SessionMock($this->app); + + $this->assertFalse(isset($_SESSION)); + $m->getApp()->session->startSession(); + $this->assertTrue(isset($_SESSION)); + $m->getApp()->session->destroySession(); + $this->assertFalse(isset($_SESSION)); + } + + /** + * Test memorize(). + */ + public function testMemorize(): void + { + $m = new SessionMock($this->app); + $m->name = 'test'; + + // value as string + $m->memorize('foo', 'bar'); + $this->assertSame('bar', $_SESSION['__atk_session'][$m->name]['foo']); + + // value as null + $m->memorize('foo', null); + $this->assertNull($_SESSION['__atk_session'][$m->name]['foo']); + + // value as object + $o = new \stdClass(); + $m->memorize('foo', $o); + $this->assertSame($o, $_SESSION['__atk_session'][$m->name]['foo']); + + $m->getApp()->session->destroySession(); + } + + /** + * Test learn(), recall(), forget(). + */ + public function testLearnRecallForget(): void + { + $m = new SessionMock($this->app); + $m->name = 'test'; + + // value as string + $m->learn('foo', 'bar'); + $this->assertSame('bar', $m->recall('foo')); + + $m->learn('foo', 'qwerty'); + $this->assertSame('bar', $m->recall('foo')); + + $m->forget('foo'); + $this->assertSame('undefined', $m->recall('foo', 'undefined')); + + // value as callback + $m->learn('foo', function ($key) { + return $key . '_bar'; + }); + $this->assertSame('foo_bar', $m->recall('foo')); + + $m->learn('foo_2', 'another'); + $this->assertSame('another', $m->recall('foo_2')); + + $v = $m->recall('foo_3', function ($key) { + return $key . '_bar'; + }); + $this->assertSame('foo_3_bar', $v); + $this->assertSame('undefined', $m->recall('foo_3', 'undefined')); + + $m->forget(); + $this->assertSame('undefined', $m->recall('foo', 'undefined')); + $this->assertSame('undefined', $m->recall('foo_2', 'undefined')); + $this->assertSame('undefined', $m->recall('foo_3', 'undefined')); + } +} + +abstract class SessionAbstractMock +{ + use AppScopeTrait; + use SessionTrait; + + public function __construct(App $app) + { + $this->setApp($app); + } +} + +class SessionMock extends SessionAbstractMock +{ + use NameTrait; +} + +class SessionWithoutNameMock extends SessionAbstractMock +{ +}