From d4558fecd510ee6fa03494e651547441f6e73342 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Vo=C5=99=C3=AD=C5=A1ek?= Date: Mon, 16 Oct 2023 10:04:59 +0200 Subject: [PATCH] WIP add atk4/mastercrud c893b74 --- mastercrud-develop/README.md | 200 ++++++++ mastercrud-develop/demos/basic.php | 31 ++ mastercrud-develop/demos/init.php | 120 +++++ mastercrud-develop/demos/mastercrud.pgsql | 527 ++++++++++++++++++++++ mastercrud-develop/src/MasterCRUD.php | 396 ++++++++++++++++ mastercrud-develop/src/MethodExecutor.php | 110 +++++ 6 files changed, 1384 insertions(+) create mode 100644 mastercrud-develop/README.md create mode 100644 mastercrud-develop/demos/basic.php create mode 100644 mastercrud-develop/demos/init.php create mode 100644 mastercrud-develop/demos/mastercrud.pgsql create mode 100644 mastercrud-develop/src/MasterCRUD.php create mode 100644 mastercrud-develop/src/MethodExecutor.php diff --git a/mastercrud-develop/README.md b/mastercrud-develop/README.md new file mode 100644 index 0000000000..f21306002c --- /dev/null +++ b/mastercrud-develop/README.md @@ -0,0 +1,200 @@ +[ATK UI](https://github.com/atk4/ui) is a UI library for building UI interfaces that has a built-in [CRUD](http://ui.agiletoolkit.org/demos/crud.php) component. It can be used to create complex admin systems, but it requires you to populate multiple pages and inter-link them together yourself. + +![mastercrud](docs/images/mastercrud.png) + +**MasterCRUD** is an add-on for ATK UI and ATK Data, which will orchestrate navigation between multiple CRUD pages by respecting relations and conditions. You can use **MasterCRUD** to: + +- Manage list of clients, and their individual invoices and payments. +- Manage user groups and users within them +- Manage multi-level catalogue and products in them + +The syntax of **MasterCRUD** is incredibly simple and short. It automatically takes care of many details like: + +- record and track `id` of various records you have clicked on (BreadCrumb) +- display multi-Tab pages with model details and optional relations +- support `hasOne` and `hasMany` relations +- allow flexible linking to a higher tree level (user - invoice - allocated_payment -> payment (drops invoice_id)) + +**MasterCRUD** can also be extended to contain your own views, you can interact with the menu and even place **MasterCRUD** inside a more complex layouts. + +### Example Use Case (see demos/clients.php for full demo): + +Assuming you have Clients with Invoices and Payments and you also want to add "Line"s for each Invoice, you may want to add this interface for the admin, where user can use drill-downs to navigate through data: + +![step1](docs/images/step1.png) + +Clicking on `Client 2` would bring you to a different page. Extra tabs Invoices and Payments offer you further way in: + +![step2](docs/images/step2.png) + +clicking on specific invoice, you can edit it's lines: + +![step3](docs/images/step3.png) + +On this screen however we turned off deletion of lines (because it is a demo). However clicking Edit brings up a Modal where you can easily update record data: + +![step4](docs/images/step4.png) + + + +All this UI can be created in just a few lines of code! + + + +MasterCRUD operates like a regular CRUD, and you can easily substitute it in: + +``` php +$crud = $app->add('\atk4\mastercrud\MasterCRUD'); +$crud->setModel('Client'); +``` + +You'll noticed that you can now click on the client name to get full details about this client. Next, we want to be able to see and manage Client invoices: + +``` php +$crud = $app->add('\atk4\mastercrud\MasterCRUD'); +$crud->setModel('Client', ['Invoices'=>[]]); +``` + +This will add 2nd tab to the "Client Details" screen listing invoices of said client. If you invoice is further broken down into "Lines", you can go one level deeper: + +``` php +$crud = $app->add('\atk4\mastercrud\MasterCRUD'); +$crud->setModel('Client', ['Invoices'=>['Lines'=>[]]]); +``` + +If `Client hasMany('Payments')` then you can also add that relation: + +``` php +$crud = $app->add('\atk4\mastercrud\MasterCRUD'); +$crud->setModel('Client', ['Invoices'=>['Lines'=>[]], 'Payments'=>[]]); +``` + +With some cleanup, this syntax is readable and nice: + +``` php +$crud = $app->add('\atk4\mastercrud\MasterCRUD'); +$crud->setModel('Client', [ + 'Invoices'=>[ + 'Lines'=>[] + ], + 'Payments'=>[] +]); +``` + +## Support for actions + +MasterCRUD is awesome for quickly creating admin systems. But basic C,R,U,D operations are not enough. Sometimes you want to invoke custom actions for individual element. MasterCRUD now supports that too: + +```php +$app->layout->add(new \atk4\mastercrud\MasterCRUD()) + ->setModel(new \saasty\Model\App($app->db), + [ + 'columnActions'=>[ + 'repair'=>['icon'=>'wrench'], + ], + 'Models'=>[ + 'columnActions'=>[ + 'migrate'=>['icon'=>'database'], + ], + 'Fields'=>[ + 'ValidationRules'=>[], + + ], + 'Relations'=>[ + 'ImportedFields'=>[], + ], + ], +``` + + ![actions](docs/images/actions.png) + +There are various invocation methods allowing you to specify icon, label, custom callbacks etc. + +This also adds "MethodInvocator" - a view which asks you for arguments and then executes them. + +This next example will use form to ask for an email, which will then be passed as argument to sendEmail($email) + +```php +[ + 'columnActions'=>[ + 'sendEmail' => ['icon'=>'wrench', 'email'=>'string'] + ] +] +``` + + + + + +### Installation + +Install through composer: + +``` bash + composer require atk4/mastercrud +``` + +Also see introduction for [ATK UI](https://github.com/atk4/ui) on how to render HTML. + +## Roadmap + +- [x] Allow to specify custom CRUD seed. You can ever replace it with your own compatible view. +- [x] Add custom actions and function invocation +- [ ] Create decent "View" mode (relies on ATK UI Card) +- [ ] Traverse hasOne references (see below) + + + + + + + + + + + + + + + +------------------------- + +> NOT IMPLEMENTED BELOW + +Suppose that `Invoice hasMany(Allocation)`and `Payment hasMany(Allocation)` while allocation can have one Payment and one Invoice. + +``` php +$crud = $app->add('\atk4\mastercrud\MasterCRUD'); +$crud->setModel('Client', [ + 'Invoices'=>[ + 'Lines'=>[], + 'Allocations'=>[] + ], + 'Payments'=>[ + 'Allocations'=>[] + ] +]); +``` + +That's cool, but if you go through the route of `Invoice -> allocation ->` you should be able to click on the "payment": + +``` php +$crud = $app->add('\atk4\mastercrud\MasterCRUD'); +$crud->setModel('Client', [ + 'Invoices'=>[ + 'Lines'=>[], + 'Allocations'=>[ + 'payment_id'=>['path'=>'Payments', 'payment_id'=>'payment_id'] + ] + ], + 'Payments'=>[ + 'Allocations'=>[ + 'invoice_id'=>['path'=>'Invoices', 'invoice_id'=>'invoice_id'] + ] + ] +]); +``` + +Now you will be able to jump from `Invoice->allocation` to `Payment` and other way around. + + diff --git a/mastercrud-develop/demos/basic.php b/mastercrud-develop/demos/basic.php new file mode 100644 index 0000000000..3fc1bcfa79 --- /dev/null +++ b/mastercrud-develop/demos/basic.php @@ -0,0 +1,31 @@ +cdn['atk'] = '../public'; +$mc = $app->add([ + MasterCRUD::class, + 'ipp' => 5, + 'quickSearch' => ['name'], +]); +$mc->setModel( + new Client($app->db), + [ + 'Invoices' => [ + 'Lines' => [ + ['_crud' => [Crud::class, 'displayFields' => ['item', 'total']]], + ], + 'Allocations' => [], + ], + 'Payments' => [ + 'Allocations' => [], + ], + ] +); diff --git a/mastercrud-develop/demos/init.php b/mastercrud-develop/demos/init.php new file mode 100644 index 0000000000..00e1d870e2 --- /dev/null +++ b/mastercrud-develop/demos/init.php @@ -0,0 +1,120 @@ +initLayout([Layout\Centered::class]); + +// change this as needed +try { + $app->db = new Persistence\Sql('pgsql://root:root@localhost/root'); +} catch (\Exception $e) { + $app->add([Message::class, 'Database is not available', 'error'])->text + ->addParagraph('Import file demos/mastercrud.pgsql and see demos/db.php') + ->addParagraph($e->getMessage()); + + exit; +} + +class Client extends Model +{ + public $table = 'client'; + + protected function init(): void + { + parent::init(); + + $this->addField('name', ['required' => true]); + $this->addField('address', ['type' => 'text']); + + $this->hasMany('Invoices', ['model' => new Invoice()]); + $this->hasMany('Payments', ['model' => new Payment()]); + } +} + +class Invoice extends Model +{ + public $table = 'invoice'; + public $title_field = 'ref_no'; + + protected function init(): void + { + parent::init(); + + $this->hasOne('client_id', ['model' => new Client()]); + + $this->addField('ref_no'); + $this->addField('status', ['enum' => ['draft', 'paid', 'partial']]); + + $this->hasMany('Lines', ['model' => new Line()]) + ->addField('total', ['aggregate' => 'sum']); + + $this->hasMany('Allocations', ['model' => new Allocation()]); + } +} + +class Line extends Model +{ + public $table = 'line'; + public $title_field = 'item'; + + protected function init() + { + parent::init(); + + $this->hasOne('invoice_id', ['model' => new Invoice()]); + $this->addField('item'); + $this->addField('qty', ['type' => 'integer']); + $this->addField('price', ['type' => 'money']); + + $this->addExpression('total', '[qty]*[price]'); + } +} + +class Payment extends Model +{ + public $table = 'payment'; + public $title_field = 'ref_no'; + + protected function init() + { + parent::init(); + + $this->hasOne('client_id', ['model' => new Client()]); + + $this->addField('ref_no'); + $this->addField('status', ['enum' => ['draft', 'allocated', 'partial']]); + $this->addField('amount', ['type' => 'money']); + + $this->hasMany('Allocations', ['model' => new Allocation()]); + } +} + +class Allocation extends Model +{ + public $table = 'allocation'; + public $title_field = 'title'; + + protected function init() + { + parent::init(); + + $this->addExpression('title', '\'Alloc \' || [id]'); + + $this->hasOne('payment_id', ['model' => new Payment()]); + $this->hasOne('invoice_id', ['model' => new Invoice()]); + $this->addField('allocated', ['type' => 'money']); + } +} diff --git a/mastercrud-develop/demos/mastercrud.pgsql b/mastercrud-develop/demos/mastercrud.pgsql new file mode 100644 index 0000000000..60678b0cab --- /dev/null +++ b/mastercrud-develop/demos/mastercrud.pgsql @@ -0,0 +1,527 @@ +-- +-- PostgreSQL database dump +-- + +-- Dumped from database version 10.4 +-- Dumped by pg_dump version 10.4 + +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SELECT pg_catalog.set_config('search_path', '', false); +SET check_function_bodies = false; +SET client_min_messages = warning; +SET row_security = off; + +-- +-- Name: plpgsql; Type: EXTENSION; Schema: -; Owner: +-- + +CREATE EXTENSION IF NOT EXISTS plpgsql WITH SCHEMA pg_catalog; + + +-- +-- Name: EXTENSION plpgsql; Type: COMMENT; Schema: -; Owner: +-- + +COMMENT ON EXTENSION plpgsql IS 'PL/pgSQL procedural language'; + + +SET default_tablespace = ''; + +SET default_with_oids = false; + +-- +-- Name: allocation; Type: TABLE; Schema: public; Owner: root +-- + +CREATE TABLE public.allocation ( + id integer NOT NULL, + payment_id integer, + invoice_id integer, + allocated numeric(8,2) +); + + +ALTER TABLE public.allocation OWNER TO root; + +-- +-- Name: allocation_id_seq; Type: SEQUENCE; Schema: public; Owner: root +-- + +CREATE SEQUENCE public.allocation_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.allocation_id_seq OWNER TO root; + +-- +-- Name: allocation_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: root +-- + +ALTER SEQUENCE public.allocation_id_seq OWNED BY public.allocation.id; + + +-- +-- Name: client; Type: TABLE; Schema: public; Owner: root +-- + +CREATE TABLE public.client ( + id integer NOT NULL, + name text, + address text +); + + +ALTER TABLE public.client OWNER TO root; + +-- +-- Name: client_id_seq; Type: SEQUENCE; Schema: public; Owner: root +-- + +CREATE SEQUENCE public.client_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.client_id_seq OWNER TO root; + +-- +-- Name: client_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: root +-- + +ALTER SEQUENCE public.client_id_seq OWNED BY public.client.id; + + +-- +-- Name: country; Type: TABLE; Schema: public; Owner: root +-- + +CREATE TABLE public.country ( + id integer NOT NULL, + iso text, + name text, + nicename text, + iso3 text, + numcode integer, + phonecode integer +); + + +ALTER TABLE public.country OWNER TO root; + +-- +-- Name: country_id_seq; Type: SEQUENCE; Schema: public; Owner: root +-- + +CREATE SEQUENCE public.country_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.country_id_seq OWNER TO root; + +-- +-- Name: country_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: root +-- + +ALTER SEQUENCE public.country_id_seq OWNED BY public.country.id; + + +-- +-- Name: invoice; Type: TABLE; Schema: public; Owner: root +-- + +CREATE TABLE public.invoice ( + id integer NOT NULL, + ref_no text, + status text, + client_id integer +); + + +ALTER TABLE public.invoice OWNER TO root; + +-- +-- Name: invoice_id_seq; Type: SEQUENCE; Schema: public; Owner: root +-- + +CREATE SEQUENCE public.invoice_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.invoice_id_seq OWNER TO root; + +-- +-- Name: invoice_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: root +-- + +ALTER SEQUENCE public.invoice_id_seq OWNED BY public.invoice.id; + + +-- +-- Name: line; Type: TABLE; Schema: public; Owner: root +-- + +CREATE TABLE public.line ( + id integer NOT NULL, + invoice_id integer, + item text, + qty integer, + price numeric(8,2) +); + + +ALTER TABLE public.line OWNER TO root; + +-- +-- Name: line_id_seq; Type: SEQUENCE; Schema: public; Owner: root +-- + +CREATE SEQUENCE public.line_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.line_id_seq OWNER TO root; + +-- +-- Name: line_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: root +-- + +ALTER SEQUENCE public.line_id_seq OWNED BY public.line.id; + + +-- +-- Name: payment; Type: TABLE; Schema: public; Owner: root +-- + +CREATE TABLE public.payment ( + id integer NOT NULL, + ref_no text, + status text, + amount numeric(8,2), + client_id integer +); + + +ALTER TABLE public.payment OWNER TO root; + +-- +-- Name: payment_id_seq; Type: SEQUENCE; Schema: public; Owner: root +-- + +CREATE SEQUENCE public.payment_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.payment_id_seq OWNER TO root; + +-- +-- Name: payment_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: root +-- + +ALTER SEQUENCE public.payment_id_seq OWNED BY public.payment.id; + + +-- +-- Name: test; Type: TABLE; Schema: public; Owner: root +-- + +CREATE TABLE public.test ( + id integer NOT NULL, + name text, + email text +); + + +ALTER TABLE public.test OWNER TO root; + +-- +-- Name: test_id_seq; Type: SEQUENCE; Schema: public; Owner: root +-- + +CREATE SEQUENCE public.test_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.test_id_seq OWNER TO root; + +-- +-- Name: test_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: root +-- + +ALTER SEQUENCE public.test_id_seq OWNED BY public.test.id; + + +-- +-- Name: allocation id; Type: DEFAULT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.allocation ALTER COLUMN id SET DEFAULT nextval('public.allocation_id_seq'::regclass); + + +-- +-- Name: client id; Type: DEFAULT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.client ALTER COLUMN id SET DEFAULT nextval('public.client_id_seq'::regclass); + + +-- +-- Name: country id; Type: DEFAULT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.country ALTER COLUMN id SET DEFAULT nextval('public.country_id_seq'::regclass); + + +-- +-- Name: invoice id; Type: DEFAULT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.invoice ALTER COLUMN id SET DEFAULT nextval('public.invoice_id_seq'::regclass); + + +-- +-- Name: line id; Type: DEFAULT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.line ALTER COLUMN id SET DEFAULT nextval('public.line_id_seq'::regclass); + + +-- +-- Name: payment id; Type: DEFAULT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.payment ALTER COLUMN id SET DEFAULT nextval('public.payment_id_seq'::regclass); + + +-- +-- Name: test id; Type: DEFAULT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.test ALTER COLUMN id SET DEFAULT nextval('public.test_id_seq'::regclass); + + +-- +-- Data for Name: allocation; Type: TABLE DATA; Schema: public; Owner: root +-- + +COPY public.allocation (id, payment_id, invoice_id, allocated) FROM stdin; +1 1 4 20.00 +\. + + +-- +-- Data for Name: client; Type: TABLE DATA; Schema: public; Owner: root +-- + +COPY public.client (id, name, address) FROM stdin; +2 Client 2 another big\nmultiline\naddress +1 John Smith Hello world address here blah blaht +4 three \N +5 four \N +6 five \N +7 six \N +8 seven \N +9 eight \N +10 nine \N +11 ten \N +12 eleven \N +13 clie \N +3 aaa aoeu +\. + + +-- +-- Data for Name: country; Type: TABLE DATA; Schema: public; Owner: root +-- + +COPY public.country (id, iso, name, nicename, iso3, numcode, phonecode) FROM stdin; +1 TH HELLO hello THT 234 \N +2 TH HELLOU helloU THT 234 \N +\. + + +-- +-- Data for Name: invoice; Type: TABLE DATA; Schema: public; Owner: root +-- + +COPY public.invoice (id, ref_no, status, client_id) FROM stdin; +3 rxx partial 3 +2 refx xxxxxx paid 3 +4 Inv 1 paid 1 +5 inv39 draft 2 +\. + + +-- +-- Data for Name: line; Type: TABLE DATA; Schema: public; Owner: root +-- + +COPY public.line (id, invoice_id, item, qty, price) FROM stdin; +1 5 aoeuaeo 4 20.00 +\. + + +-- +-- Data for Name: payment; Type: TABLE DATA; Schema: public; Owner: root +-- + +COPY public.payment (id, ref_no, status, amount, client_id) FROM stdin; +1 XX draft 200.00 2 +\. + + +-- +-- Data for Name: test; Type: TABLE DATA; Schema: public; Owner: root +-- + +COPY public.test (id, name, email) FROM stdin; +1 roman tenst +\. + + +-- +-- Name: allocation_id_seq; Type: SEQUENCE SET; Schema: public; Owner: root +-- + +SELECT pg_catalog.setval('public.allocation_id_seq', 1, true); + + +-- +-- Name: client_id_seq; Type: SEQUENCE SET; Schema: public; Owner: root +-- + +SELECT pg_catalog.setval('public.client_id_seq', 13, true); + + +-- +-- Name: country_id_seq; Type: SEQUENCE SET; Schema: public; Owner: root +-- + +SELECT pg_catalog.setval('public.country_id_seq', 2, true); + + +-- +-- Name: invoice_id_seq; Type: SEQUENCE SET; Schema: public; Owner: root +-- + +SELECT pg_catalog.setval('public.invoice_id_seq', 5, true); + + +-- +-- Name: line_id_seq; Type: SEQUENCE SET; Schema: public; Owner: root +-- + +SELECT pg_catalog.setval('public.line_id_seq', 1, true); + + +-- +-- Name: payment_id_seq; Type: SEQUENCE SET; Schema: public; Owner: root +-- + +SELECT pg_catalog.setval('public.payment_id_seq', 1, true); + + +-- +-- Name: test_id_seq; Type: SEQUENCE SET; Schema: public; Owner: root +-- + +SELECT pg_catalog.setval('public.test_id_seq', 1, true); + + +-- +-- Name: allocation allocation_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.allocation + ADD CONSTRAINT allocation_pkey PRIMARY KEY (id); + + +-- +-- Name: client client_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.client + ADD CONSTRAINT client_pkey PRIMARY KEY (id); + + +-- +-- Name: country country_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.country + ADD CONSTRAINT country_pkey PRIMARY KEY (id); + + +-- +-- Name: invoice invoice_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.invoice + ADD CONSTRAINT invoice_pkey PRIMARY KEY (id); + + +-- +-- Name: line line_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.line + ADD CONSTRAINT line_pkey PRIMARY KEY (id); + + +-- +-- Name: payment payment_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.payment + ADD CONSTRAINT payment_pkey PRIMARY KEY (id); + + +-- +-- Name: test test_pkey; Type: CONSTRAINT; Schema: public; Owner: root +-- + +ALTER TABLE ONLY public.test + ADD CONSTRAINT test_pkey PRIMARY KEY (id); + + +-- +-- PostgreSQL database dump complete +-- + diff --git a/mastercrud-develop/src/MasterCRUD.php b/mastercrud-develop/src/MasterCRUD.php new file mode 100644 index 0000000000..ff94026803 --- /dev/null +++ b/mastercrud-develop/src/MasterCRUD.php @@ -0,0 +1,396 @@ + 25]; + + /** @var array Default Tabs for all model. You may override this value per model using['_tabs'] in setModel */ + public $defaultTabs = [Tabs::class]; + + /** @var array Default Card for all model. You may override this value per model using['_card'] in setModel */ + public $defaultCard = [CardTable::class]; + + /** + * Initialization. + */ + protected function init(): void + { + if (in_array($this->pathDelimiter, ['?', '#', '/'], true)) { + throw new Exception('Can\'t use URL reserved charater (?,#,/) as path delimiter'); + } + + // add Breadcrumb view + if (!$this->crumb) { + $this->crumb = Breadcrumb::addTo($this, $this->defaultCrumb); + } + $this->add([View::class, 'ui' => 'divider']); + + parent::init(); + } + + /** + * Sets model. + * + * Use $defs['_crud'] to set seed properties for Crud view. + * Use $defs['_tabs'] to set seed properties for Tabs view. + * Use $defs['_card'] to set seed properties for Card view. + * + * For example setting different seeds for Client and Invoice model passing seeds value in array 0. + * $mc->setModel(new Client($app->db), + * [ + * ['_crud' => ['Crud', 'ipp' => 50]], + * 'Invoices'=>[ + * [ + * '_crud' =>['Crud', 'ipp' => 25, 'displayFields' => ['reference', 'total']], + * '_card' =>['Card', 'useLabel' => true] + * ], + * 'Lines'=>[], + * 'Allocations'=>[] + * ], + * 'Payments'=>[ + * 'Allocations'=>[] + * ] + * ] + * ); + */ + public function setModel(Model $m, array $defs = null): Model + { + $this->rootModel = $m; + + $this->crumb->addCrumb($this->getCaption($m), $this->url()); + + // extract path + $this->path = explode($this->pathDelimiter, $this->getApp()->stickyGet('path') ?? ''); + if ($this->path[0] === '') { + unset($this->path[0]); + } + + $defs = $this->traverseModel($this->path, $defs ?? []); + + $arg_name = $this->model->table . '_id'; + $arg_val = $this->getApp()->stickyGet($arg_name); + if ($arg_val && $this->model->tryLoad($arg_val)->loaded()) { + // initialize Tabs + $this->initTabs($defs); + } else { + // initialize CRUD + $this->initCrud($defs); + } + + $this->crumb->popTitle(); + + return $this->rootModel; + } + + /** + * Return model caption. + */ + public function getCaption(Model $m): string + { + return $m->getModelCaption(); + } + + /** + * Return title field value. + */ + public function getTitle(Model $m): string + { + return $m->getTitle(); + } + + /** + * Initialize tabs. + * + * @param View $view Parent view + */ + public function initTabs(array $defs, View $view = null) + { + if ($view === null) { + $view = $this; + } + + $this->tabs = $view->add($this->getTabsSeed($defs)); + $this->getApp()->stickyGet($this->model->table . '_id'); + + $this->crumb->addCrumb($this->getTitle($this->model), $this->tabs->url()); + + // Use callback to refresh detail tabs when related model is changed. + $this->tabs->addTab($this->detailLabel, function ($p) use ($defs) { + $card = $p->add($this->getCardSeed($defs)); + $card->setModel($this->model); + }); + + if (!$defs) { + return; + } + + foreach ($defs as $ref => $subdef) { + if (is_numeric($ref) || in_array($ref, $this->reserved_properties, true)) { + continue; + } + $m = $this->model->ref($ref); + + $caption = $this->model->getRef($ref)->caption ?? $this->getCaption($m); + + $this->tabs->addTab($caption, function ($p) use ($subdef, $m, $ref) { + $sub_crud = Crud::addTo($p, $this->getCRUDSeed($subdef)); + + $sub_crud->setModel(clone $m); + $t = $p->urlTrigger ?: $p->name; + + if (isset($sub_crud->table->columns[$m->title_field])) { + // DEV-Note + // This cause issue since https://github.com/atk4/ui/pull/1397 cause it will always include __atk_callback argument. + // $sub_crud->addDecorator($m->title_field, [Table\Column\Link::class, [$t => false, 'path' => $this->getPath($ref)], [$m->table . '_id' => 'id']]); + + // Creating url template in order to produce proper url. + $sub_crud->addDecorator($m->title_field, [Table\Column\Link::class, 'url' => $this->getApp()->url(['path' => $this->getPath($ref)]) . '&' . $m->table . '_id=' . '{$id}']); + } + + $this->addActions($sub_crud, $subdef); + }); + } + } + + /** + * Initialize CRUD. + * + * @param View $view Parent view + */ + public function initCrud(array $defs, View $view = null) + { + if ($view === null) { + $view = $this; + } + + $crud = Crud::addTo($view, $this->getCRUDSeed($defs)); + $crud->setModel($this->model); + + if (isset($crud->table->columns[$this->model->title_field])) { + $crud->addDecorator($this->model->title_field, [Table\Column\Link::class, [], [$this->model->table . '_id' => 'id']]); + } + + $this->addActions($crud, $defs); + } + + /** + * Provided with a relative path, add it to the current one + * and return string. + * + * @param string|array $rel + * + * @return false|string + */ + public function getPath($rel) + { + $path = $this->path; + + if (!is_array($rel)) { + $rel = explode($this->pathDelimiter, $rel); + } + + foreach ($rel as $rel_one) { + if ($rel_one === '..') { + array_pop($path); + + continue; + } + + if ($rel_one === '') { + $path = []; + + continue; + } + + $path[] = $rel_one; + } + + $res = implode($this->pathDelimiter, $path); + + return $res === '' ? false : $res; + } + + /** + * Adds CRUD action buttons. + */ + public function addActions(View $crud, array $defs) + { + if ($ma = $defs['menuActions'] ?? null) { + is_array($ma) || $ma = [$ma]; + + foreach ($ma as $key => $action) { + if (is_numeric($key)) { + $key = $action; + } + + if (is_string($action)) { + $crud->menu->addItem($key)->on( + 'click', + new JsModal('Executing ' . $key, $this->add([VirtualPage::class])->set(function ($p) use ($key, $crud) { + // TODO: this does ont work within a tab :( + $p->add(new MethodExecutor($crud->model, $key)); + })) + ); + } + + if ($action instanceof \Closure) { + $crud->menu->addItem($key)->on( + 'click', + new JsModal('Executing ' . $key, $this->add([VirtualPage::class])->set(function ($p) use ($key, $action) { + $action($p, $this->model, $key); + })) + ); + } + } + } + + if ($ca = $defs['columnActions'] ?? null) { + is_array($ca) || $ca = [$ca]; + + foreach ($ca as $key => $action) { + if (is_numeric($key)) { + $key = $action; + } + + if (is_string($action)) { + $label = ['icon' => $action]; + } + + is_array($action) || $action = [$action]; + + if (isset($action['icon'])) { + $label = ['icon' => $action['icon']]; + unset($action['icon']); + } + + if (isset($action[0]) && $action[0] instanceof \Closure) { + $crud->addModalAction($label ?: $key, $key, function ($p, $id) use ($action, $crud) { + call_user_func($action[0], $p, $crud->model->load($id)); + }); + } else { + $crud->addModalAction($label ?: $key, $key, function ($p, $id) use ($action, $key, $crud) { + $p->add(new MethodExecutor($crud->model->load($id), $key, $action)); + }); + } + } + } + } + + /** + * Return seed for CRUD. + * + * @return array|View + */ + protected function getCRUDSeed(array $defs) + { + return $defs[0]['_crud'] ?? $this->defaultCrud; + } + + /** + * Return seed for Tabs. + * + * @return array|View + */ + protected function getTabsSeed(array $defs) + { + return $defs[0]['_tabs'] ?? $this->defaultTabs; + } + + /** + * Return seed for Card. + * + * @return array|View + */ + protected function getCardSeed(array $defs) + { + return $defs[0]['_card'] ?? $this->defaultCard; + } + + /** + * Given a path and arguments, find and load the right model. + */ + public function traverseModel(array $path, array $defs): array + { + $m = $this->rootModel; + + $path_part = ['']; + + foreach ($path as $p) { + if (!$p) { + continue; + } + + if (!isset($defs[$p])) { + throw (new Exception('Path is not defined')) + ->addMoreInfo('path', $path) + ->addMoreInfo('defs', $defs); + } + + $defs = $defs[$p]; + + // argument of a current model should be passed if we are traversing + $arg_name = $m->table . '_id'; + $arg_val = $this->getApp()->stickyGet($arg_name); + + if ($arg_val === null) { + throw (new Exception('Argument value is not specified')) + ->addMoreInfo('arg', $arg_name); + } + + // load record and traverse + $m->load($arg_val); + + $this->crumb->addCrumb( + $this->getTitle($m), + $this->url(['path' => $this->getPath($path_part)]) + ); + + $m = $m->ref($p); + $path_part[] = $p; + } + + parent::setModel($m); + + return $defs; + } +} diff --git a/mastercrud-develop/src/MethodExecutor.php b/mastercrud-develop/src/MethodExecutor.php new file mode 100644 index 0000000000..924f9496ab --- /dev/null +++ b/mastercrud-develop/src/MethodExecutor.php @@ -0,0 +1,110 @@ +add(new MethodExecutor($user, 'generatePassword', ['integer'])); + * + * Possible values of 3rd argument would be: + * + * - string, would define 'type'=>$type explicitly, e.g. 'boolean' or 'date'. + * - callback, would be executed and return value used. function() { return 123; } + * - array - use a seed for creating model field + */ +class MethodExecutor extends View +{ + use SessionTrait; + + /** @var Model */ + public $model; + + /** @var string */ + public $method; + + /** @var array */ + public $defs; + + /** + * Constructor. + */ + public function __construct(Model $model, string $method, array $defs = []) + { + parent::__construct([ + 'model' => $model, + 'method' => $method, + 'defs' => $defs, + ]); + } + + /** + * Initialization. + */ + protected function init(): void + { + parent::init(); + + $this->console = $this->add([Console::class, 'event' => false]); //->addStyle('display', 'none'); + $this->console->addStyle('max-height', '50em')->addStyle('overflow', 'scroll'); + + $this->form = $this->add([Form::class]); + + foreach ($this->defs as $key => $val) { + if (is_numeric($key)) { + $key = 'Argument' . $key; + } + + if (is_callable($val)) { + continue; + } + + if ($val instanceof Model) { + $this->form->addControl($key, [Form\Control\Lookup::class])->setModel($val); + } else { + $this->form->addControl($key, null, $val); + } + } + + $this->form->buttonSave->set('Run'); + + $this->form->onSubmit(function ($f) { + $this->memorize('data', $f->model ? $f->model->get() : []); + + return [$this->console->js()->show(), $this->console->sse]; + }); + + $this->console->set(function ($c) { + $data = $this->recall('data'); + $args = []; + + foreach ($this->defs as $key => $val) { + if (is_numeric($key)) { + $key = 'Argument' . $key; + } + + if (is_callable($val)) { + $val = $val($this->model, $this->method, $data); + } elseif ($val instanceof Model) { + $val->load($data[$key]); + } else { + $val = $data[$key]; + } + + $args[] = $val; + } + + $c->runMethod($this->model, $this->method, $args); + }); + } +}