1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace Atk4\Data\Model;
6:
7: use Atk4\Core\DiContainerTrait;
8: use Atk4\Core\Exception as CoreException;
9: use Atk4\Core\InitializerTrait;
10: use Atk4\Core\TrackableTrait;
11: use Atk4\Data\Exception;
12: use Atk4\Data\Model;
13:
14: /**
15: * Implements generic user action. Assigned to a model it can be invoked by a user. Model\UserAction class contains a
16: * meta information about the action (arguments, permissions, appliesTo records, etc) that will help UI/API or add-ons to display
17: * action trigger (button) correctly in an automated way.
18: *
19: * UserAction must NOT rely on any specific UI implementation.
20: *
21: * @method false getOwner() use getModel() or getEntity() method instead
22: */
23: class UserAction
24: {
25: use DiContainerTrait;
26: use InitializerTrait;
27: use TrackableTrait;
28:
29: /** Defining records scope of the action */
30: public const APPLIES_TO_NO_RECORDS = 'none'; // e.g. add
31: public const APPLIES_TO_SINGLE_RECORD = 'single'; // e.g. archive
32: public const APPLIES_TO_MULTIPLE_RECORDS = 'multiple'; // e.g. delete
33: public const APPLIES_TO_ALL_RECORDS = 'all'; // e.g. truncate
34:
35: /** Defining action modifier */
36: public const MODIFIER_CREATE = 'create'; // create new record(s)
37: public const MODIFIER_UPDATE = 'update'; // update existing record(s)
38: public const MODIFIER_DELETE = 'delete'; // delete record(s)
39: public const MODIFIER_READ = 'read'; // just read, does not modify record(s)
40:
41: /** @var string by default action is for a single record */
42: public $appliesTo = self::APPLIES_TO_SINGLE_RECORD;
43:
44: /** @var string How this action interact with record */
45: public $modifier;
46:
47: /** @var \Closure(object, mixed, mixed, mixed, mixed, mixed, mixed, mixed, mixed, mixed, mixed): mixed|string code to execute. By default will call entity method with same name */
48: public $callback;
49:
50: /** @var \Closure(object, mixed, mixed, mixed, mixed, mixed, mixed, mixed, mixed, mixed, mixed): mixed|string identical to callback, but would generate preview of action without permanent effect */
51: public $preview;
52:
53: /** @var string|null caption to put on the button */
54: public $caption;
55:
56: /** @var string|\Closure($this): string|null a longer description of this action. */
57: public $description;
58:
59: /** @var bool|string|\Closure($this): string Will ask user to confirm. */
60: public $confirmation = false;
61:
62: /** @var bool|\Closure(object): bool setting this to false will disable action. */
63: public $enabled = true;
64:
65: /** @var bool system action will be hidden from UI, but can still be explicitly triggered */
66: public $system = false;
67:
68: /** @var array<string, array<string, mixed>|Model> Argument definition. */
69: public $args = [];
70:
71: /** @var array<int, string>|bool Specify which fields may be dirty when invoking action. APPLIES_TO_NO_RECORDS|APPLIES_TO_SINGLE_RECORD scopes for adding/modifying */
72: public $fields = [];
73:
74: /** @var bool Atomic action will automatically begin transaction before and commit it after completing. */
75: public $atomic = true;
76:
77: private function _getOwner(): Model
78: {
79: return $this->getOwner(); // @phpstan-ignore-line;
80: }
81:
82: public function isOwnerEntity(): bool
83: {
84: $owner = $this->_getOwner();
85:
86: return $owner->isEntity();
87: }
88:
89: public function getModel(): Model
90: {
91: $owner = $this->_getOwner();
92:
93: return $owner->getModel(true);
94: }
95:
96: public function getEntity(): Model
97: {
98: $owner = $this->_getOwner();
99: $owner->assertIsEntity();
100:
101: return $owner;
102: }
103:
104: /**
105: * @return static
106: */
107: public function getActionForEntity(Model $entity): self
108: {
109: $owner = $this->_getOwner();
110:
111: $entity->assertIsEntity($owner);
112: foreach ($owner->getUserActions() as $name => $action) {
113: if ($action === $this) {
114: return $entity->getUserAction($name); // @phpstan-ignore-line
115: }
116: }
117:
118: throw new Exception('Action instance not found in model');
119: }
120:
121: /**
122: * Attempt to execute callback of the action.
123: *
124: * @param mixed ...$args
125: *
126: * @return mixed
127: */
128: public function execute(...$args)
129: {
130: $passOwner = false;
131: if ($this->callback === null) {
132: $fx = \Closure::fromCallable([$this->_getOwner(), $this->shortName]);
133: } elseif (is_string($this->callback)) {
134: $fx = \Closure::fromCallable([$this->_getOwner(), $this->callback]);
135: } else {
136: $passOwner = true;
137: $fx = $this->callback;
138: }
139:
140: // todo - ACL tests must allow
141:
142: try {
143: $this->validateBeforeExecute();
144:
145: if ($passOwner) {
146: array_unshift($args, $this->_getOwner());
147: }
148:
149: return $this->atomic === false
150: ? $fx(...$args)
151: : $this->_getOwner()->atomic(static fn () => $fx(...$args));
152: } catch (CoreException $e) {
153: $e->addMoreInfo('action', $this);
154:
155: throw $e;
156: }
157: }
158:
159: protected function validateBeforeExecute(): void
160: {
161: if ($this->enabled === false || ($this->enabled instanceof \Closure && ($this->enabled)($this->_getOwner()) === false)) {
162: throw new Exception('User action is disabled');
163: }
164:
165: if (!is_bool($this->fields) && $this->isOwnerEntity()) {
166: $dirtyFields = array_keys($this->getEntity()->getDirtyRef());
167: $tooDirtyFields = array_diff($dirtyFields, $this->fields);
168:
169: if ($tooDirtyFields !== []) {
170: throw (new Exception('User action cannot be executed when unrelated fields are dirty'))
171: ->addMoreInfo('tooDirtyFields', $tooDirtyFields)
172: ->addMoreInfo('otherDirtyFields', array_diff($dirtyFields, $tooDirtyFields));
173: }
174: }
175:
176: switch ($this->appliesTo) {
177: case self::APPLIES_TO_NO_RECORDS:
178: if ($this->getEntity()->isLoaded()) {
179: throw (new Exception('User action can be executed on new entity only'))
180: ->addMoreInfo('id', $this->getEntity()->getId());
181: }
182:
183: break;
184: case self::APPLIES_TO_SINGLE_RECORD:
185: if (!$this->getEntity()->isLoaded()) {
186: throw new Exception('User action can be executed on loaded entity only');
187: }
188:
189: break;
190: case self::APPLIES_TO_MULTIPLE_RECORDS:
191: case self::APPLIES_TO_ALL_RECORDS:
192: $this->_getOwner()->assertIsModel();
193:
194: break;
195: }
196: }
197:
198: /**
199: * Identical to Execute but display a preview of what will happen.
200: *
201: * @param mixed ...$args
202: *
203: * @return mixed
204: */
205: public function preview(...$args)
206: {
207: $passOwner = false;
208: if (is_string($this->preview)) {
209: $fx = \Closure::fromCallable([$this->_getOwner(), $this->preview]);
210: } else {
211: $passOwner = true;
212: $fx = $this->preview;
213: }
214:
215: try {
216: $this->validateBeforeExecute();
217:
218: if ($passOwner) {
219: array_unshift($args, $this->_getOwner());
220: }
221:
222: return $fx(...$args);
223: } catch (CoreException $e) {
224: $e->addMoreInfo('action', $this);
225:
226: throw $e;
227: }
228: }
229:
230: /**
231: * Get description of this current action in a user-understandable language.
232: */
233: public function getDescription(): string
234: {
235: if ($this->description instanceof \Closure) {
236: return ($this->description)($this);
237: }
238:
239: return $this->description ?? $this->getCaption() . ' ' . $this->getModel()->getModelCaption();
240: }
241:
242: /**
243: * Return confirmation message for action.
244: *
245: * @return string|false
246: */
247: public function getConfirmation()
248: {
249: if ($this->confirmation instanceof \Closure) {
250: return ($this->confirmation)($this);
251: } elseif ($this->confirmation === true) {
252: $confirmation = 'Are you sure you wish to execute '
253: . $this->getCaption()
254: . ($this->isOwnerEntity() && $this->getEntity()->getTitle() ? ' using ' . $this->getEntity()->getTitle() : '')
255: . '?';
256:
257: return $confirmation;
258: }
259:
260: return $this->confirmation;
261: }
262:
263: public function getCaption(): string
264: {
265: return $this->caption ?? $this->getModel()->readableCaption($this->shortName);
266: }
267: }
268: