1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace Atk4\Ui;
6:
7: use Atk4\Core\Factory;
8: use Atk4\Data\Field;
9: use Atk4\Data\Model;
10: use Atk4\Data\Model\EntityFieldPair;
11: use Atk4\Data\Reference\ContainsMany;
12: use Atk4\Data\ValidationException;
13: use Atk4\Ui\Form\Control;
14: use Atk4\Ui\Js\Jquery;
15: use Atk4\Ui\Js\JsBlock;
16: use Atk4\Ui\Js\JsChain;
17: use Atk4\Ui\Js\JsConditionalForm;
18: use Atk4\Ui\Js\JsExpression;
19: use Atk4\Ui\Js\JsExpressionable;
20:
21: class Form extends View
22: {
23: use \Atk4\Core\HookTrait;
24:
25: /** Executed when form is submitted */
26: public const HOOK_SUBMIT = self::class . '@submit';
27: /** Executed when form is submitted */
28: public const HOOK_DISPLAY_ERROR = self::class . '@displayError';
29: /** Executed when form is submitted */
30: public const HOOK_DISPLAY_SUCCESS = self::class . '@displaySuccess';
31: /** Executed when self::loadPost() method is called. */
32: public const HOOK_LOAD_POST = self::class . '@loadPost';
33:
34: public $ui = 'form';
35: public $defaultTemplate = 'form.html';
36:
37: /** @var JsCallback Callback handling form submission. */
38: public $cb;
39:
40: /** @var bool Set this to false in order to prevent from leaving page if form is not submit. */
41: public $canLeave = true;
42:
43: /**
44: * HTML <form> element, all inner form controls are linked to it on render
45: * with HTML form="form_id" attribute.
46: *
47: * @var View
48: */
49: public $formElement;
50:
51: /** @var Form\Layout A current layout of a form, needed if you call Form->addControl(). */
52: public $layout;
53:
54: /** @var array<string, Control> List of form controls currently registered with this form. */
55: public $controls = [];
56:
57: /**
58: * Will point to the Save button. If you don't want to have save button, then set this to false
59: * or destroy it. Initialized by initLayout().
60: *
61: * @var Button|array|false Button object, seed or false to not show button at all
62: */
63: public $buttonSave = [Button::class, 'Save', 'class.primary' => true];
64:
65: /**
66: * When form is submitted successfully, this template is used by method
67: * jsSuccess() to replace form contents.
68: *
69: * WARNING: may be removed in the future as we refactor into using Message class
70: * and remove the form-success.html template then.
71: *
72: * @var string
73: */
74: public $successTemplate = 'form-success.html';
75:
76: /**
77: * Collection of field's conditions for displaying a target field on the form.
78: *
79: * Specifying a condition for showing a target field required the name of the target field
80: * and the rules to show that target field. Each rule contains a source field's name and a condition for the
81: * source field. When each rule is true, then the target field is show on the form.
82: *
83: * Combine multiple rules for showing a field.
84: * ex: ['target' => ['source1' => 'notEmpty', 'source2' => 'notEmpty']]
85: * Show 'target' if 'source1' is not empty AND 'source2' is notEmpty.
86: *
87: * Combine multiple condition to the same source field.
88: * ex: ['target' => ['source1' => ['notEmpty', 'number']]
89: * Show 'target' if 'source1 is notEmpty AND is a number.
90: *
91: * Combine multiple arrays of rules will OR the rules for the target field.
92: * ex: ['target' => [['source1' => ['notEmpty', 'number']], ['source1' => 'isExactly[5]']
93: * Show "target' if 'source1' is not empty AND is a number
94: * OR
95: * Show 'target' if 'source1' is exactly 5.
96: */
97: public array $controlDisplayRules = [];
98:
99: /**
100: * Default CSS selector for JsConditionalForm.
101: * Should match the CSS class name of the control.
102: * Fomantic-UI use the class name "field".
103: *
104: * @var string
105: */
106: public $controlDisplaySelector = '.field';
107:
108: /** @var array Use this apiConfig variable to pass API settings to Fomantic-UI in .api(). */
109: public $apiConfig = [];
110:
111: /** @var array Use this formConfig variable to pass settings to Fomantic-UI in .from(). */
112: public $formConfig = [];
113:
114: // {{{ Base Methods
115:
116: #[\Override]
117: protected function init(): void
118: {
119: parent::init();
120:
121: $this->formElement = View::addTo($this, ['element' => 'form', 'shortName' => 'form'], ['FormElementOnly']);
122:
123: // redirect submit event to native form element
124: $this->on(
125: 'submit',
126: new JsExpression('if (event.target === this) { event.stopImmediatePropagation(); [] }', [new JsBlock([$this->formElement->js()->trigger('submit')])]),
127: ['stopPropagation' => false]
128: );
129:
130: $this->initLayout();
131:
132: // set CSS loader for this form
133: $this->setApiConfig(['stateContext' => $this]);
134:
135: $this->cb = JsCallback::addTo($this, [], [['desired_name' => 'submit']]);
136: }
137:
138: protected function initLayout(): void
139: {
140: if (!is_object($this->layout)) { // @phpstan-ignore-line
141: $this->layout = Factory::factory($this->layout ?? [Form\Layout::class]); // @phpstan-ignore-line
142: }
143: $this->layout->form = $this;
144: $this->add($this->layout);
145:
146: // add save button in layout
147: if ($this->buttonSave) {
148: $this->buttonSave = $this->layout->addButton($this->buttonSave);
149: $this->buttonSave->setAttr('tabindex', 0);
150: $jsSubmit = $this->js()->form('submit');
151: $this->buttonSave->on('click', $jsSubmit);
152: $this->buttonSave->on('keypress', new JsExpression('if (event.keyCode === 13) { [] }', [new JsBlock([$jsSubmit])]));
153: }
154: }
155:
156: /**
157: * Setter for control display rules.
158: *
159: * @param array $rules
160: *
161: * @return $this
162: */
163: public function setControlsDisplayRules($rules = [])
164: {
165: $this->controlDisplayRules = $rules;
166:
167: return $this;
168: }
169:
170: /**
171: * Set display rule for a group collection.
172: *
173: * @param array $rules
174: * @param string|View $selector
175: *
176: * @return $this
177: */
178: public function setGroupDisplayRules($rules = [], $selector = '.atk-form-group')
179: {
180: if (is_object($selector)) {
181: $selector = '#' . $selector->getHtmlId();
182: }
183:
184: $this->controlDisplayRules = $rules;
185: $this->controlDisplaySelector = $selector;
186:
187: return $this;
188: }
189:
190: /**
191: * @param array<int, string>|null $fields if null, then all "editable" fields will be added
192: */
193: #[\Override]
194: public function setModel(Model $entity, array $fields = null): void
195: {
196: $entity->assertIsEntity();
197:
198: // set model for the form and also for the current layout
199: try {
200: parent::setModel($entity);
201:
202: $this->layout->setModel($entity, $fields);
203: } catch (Exception $e) {
204: throw $e->addMoreInfo('model', $entity);
205: }
206: }
207:
208: /**
209: * Adds callback in submit hook.
210: *
211: * @param \Closure($this): (JsExpressionable|View|string|void) $fx
212: *
213: * @return $this
214: */
215: public function onSubmit(\Closure $fx)
216: {
217: $this->onHook(self::HOOK_SUBMIT, $fx);
218:
219: $this->cb->set(function () {
220: try {
221: $this->loadPost();
222:
223: $response = $this->hook(self::HOOK_SUBMIT);
224: // TODO JsBlock::fromHookResult() cannot be used here as long as the result can contain View
225: if (is_array($response) && count($response) === 1) {
226: $response = reset($response);
227: }
228:
229: return $response;
230: } catch (ValidationException $e) {
231: $response = new JsBlock();
232: foreach ($e->errors as $field => $error) {
233: if (!isset($this->controls[$field])) {
234: throw $e;
235: }
236:
237: $response->addStatement($this->jsError($field, $error));
238: }
239:
240: return $response;
241: }
242: });
243:
244: return $this;
245: }
246:
247: /**
248: * Return form control associated with the field.
249: *
250: * @param string $name Name of the control
251: */
252: public function getControl(string $name): Control
253: {
254: return $this->controls[$name];
255: }
256:
257: /**
258: * Causes form to generate error.
259: *
260: * @param string $errorMessage
261: */
262: public function jsError(string $fieldName, $errorMessage): JsExpressionable
263: {
264: // by using this hook you can overwrite default behavior of this method
265: if ($this->hookHasCallbacks(self::HOOK_DISPLAY_ERROR)) {
266: return JsBlock::fromHookResult($this->hook(self::HOOK_DISPLAY_ERROR, [$fieldName, $errorMessage]));
267: }
268:
269: return new JsBlock([$this->js()->form('add prompt', $fieldName, $errorMessage)]);
270: }
271:
272: /**
273: * Causes form to generate success message.
274: *
275: * @param View|string $success Success message or a View to display in modal
276: * @param string $subHeader Sub-header
277: * @param bool $useTemplate Backward compatibility
278: */
279: public function jsSuccess($success = 'Success', $subHeader = null, bool $useTemplate = true): JsExpressionable
280: {
281: $response = null;
282: // by using this hook you can overwrite default behavior of this method
283: if ($this->hookHasCallbacks(self::HOOK_DISPLAY_SUCCESS)) {
284: return JsBlock::fromHookResult($this->hook(self::HOOK_DISPLAY_SUCCESS, [$success, $subHeader]));
285: }
286:
287: if ($success instanceof View) {
288: $response = $success;
289: } elseif ($useTemplate) {
290: $responseTemplate = $this->getApp()->loadTemplate($this->successTemplate);
291: $responseTemplate->set('header', $success);
292:
293: if ($subHeader) {
294: $responseTemplate->set('message', $subHeader);
295: } else {
296: $responseTemplate->del('p');
297: }
298:
299: $response = $this->js()->html($responseTemplate->renderToHtml());
300: } else {
301: $response = new Message([$success, 'type' => 'success', 'icon' => 'check']);
302: $response->setApp($this->getApp());
303: $response->invokeInit();
304: $response->text->addParagraph($subHeader);
305: }
306:
307: return $response;
308: }
309:
310: // }}}
311:
312: // {{{ Layout Manipulation
313:
314: /**
315: * Add form control into current layout. If no layout, create one. If no model, create blank one.
316: *
317: * @param array<mixed>|Control $control
318: * @param array<mixed> $fieldSeed
319: */
320: public function addControl(string $name, $control = [], array $fieldSeed = []): Control
321: {
322: return $this->layout->addControl($name, $control, $fieldSeed);
323: }
324:
325: /**
326: * Add header into the form, which appears as a separator.
327: *
328: * @param string|array $title
329: */
330: public function addHeader($title = null): void
331: {
332: $this->layout->addHeader($title);
333: }
334:
335: /**
336: * Creates a group of fields and returns layout.
337: *
338: * @param string|array $title
339: *
340: * @return Form\Layout
341: */
342: public function addGroup($title = null)
343: {
344: return $this->layout->addGroup($title);
345: }
346:
347: // }}}
348:
349: // {{{ Internals
350:
351: /**
352: * Provided with a Agile Data Model Field, this method have to decide
353: * and create instance of a View that will act as a form-control. It takes
354: * various input and looks for hints as to which class to use:.
355: *
356: * 1. The $seed argument is evaluated
357: * 2. $f->ui['form'] is evaluated if present
358: * 3. $f->type is converted into seed and evaluated
359: * 4. lastly, falling back to Line, Dropdown (based on $reference and $enum)
360: *
361: * @param array<string, mixed> $controlSeed
362: */
363: public function controlFactory(Field $field, $controlSeed = []): Control
364: {
365: $this->model->assertIsEntity($field->getOwner());
366:
367: $fallbackSeed = [Control\Line::class];
368:
369: if ($field->type === 'json' && $field->hasReference()) {
370: $limit = ($field->getReference() instanceof ContainsMany) ? 0 : 1;
371: $model = $field->getReference()->refModel($this->model);
372: $fallbackSeed = [Control\Multiline::class, 'model' => $model, 'rowLimit' => $limit, 'caption' => $model->getModelCaption()];
373: } elseif ($field->type !== 'boolean') {
374: if ($field->enum) {
375: $fallbackSeed = [Control\Dropdown::class, 'values' => array_combine($field->enum, $field->enum)];
376: } elseif ($field->values) {
377: $fallbackSeed = [Control\Dropdown::class, 'values' => $field->values];
378: } elseif ($field->hasReference()) {
379: $fallbackSeed = [Control\Lookup::class, 'model' => $field->getReference()->refModel($this->model)];
380: }
381: }
382:
383: if (isset($field->ui['hint'])) {
384: $fallbackSeed['hint'] = $field->ui['hint'];
385: }
386:
387: if (isset($field->ui['placeholder'])) {
388: $fallbackSeed['placeholder'] = $field->ui['placeholder'];
389: }
390:
391: $controlSeed = Factory::mergeSeeds(
392: $controlSeed,
393: $field->ui['form'] ?? null,
394: $this->typeToControl[$field->type] ?? null,
395: $fallbackSeed
396: );
397:
398: $defaults = [
399: 'form' => $this,
400: 'entityField' => new EntityFieldPair($this->model, $field->shortName),
401: 'shortName' => $field->shortName,
402: ];
403:
404: return Factory::factory($controlSeed, $defaults);
405: }
406:
407: /**
408: * @var array<string, array>
409: */
410: protected array $typeToControl = [
411: 'boolean' => [Control\Checkbox::class],
412: 'text' => [Control\Textarea::class],
413: 'datetime' => [Control\Calendar::class, 'type' => 'datetime'],
414: 'date' => [Control\Calendar::class, 'type' => 'date'],
415: 'time' => [Control\Calendar::class, 'type' => 'time'],
416: 'atk4_money' => [Control\Money::class],
417: ];
418:
419: /**
420: * Looks inside the POST of the request and loads it into a current model.
421: */
422: protected function loadPost(): void
423: {
424: $postRawData = $this->getApp()->getRequest()->getParsedBody();
425: $this->hook(self::HOOK_LOAD_POST, [&$postRawData]);
426:
427: $errors = [];
428: foreach ($this->controls as $k => $control) {
429: // save field value only if field was editable in form at all
430: if (!$control->readOnly && !$control->disabled) {
431: $postRawValue = $postRawData[$k] ?? null;
432: if ($postRawValue === null) {
433: throw (new Exception('Form POST param does not exist'))
434: ->addMoreInfo('key', $k);
435: }
436:
437: try {
438: $control->set($this->getApp()->uiPersistence->typecastLoadField($control->entityField->getField(), $postRawValue));
439: } catch (\Exception $e) {
440: if ($e instanceof \ErrorException) {
441: throw $e;
442: }
443:
444: $messages = [];
445: do {
446: $messages[] = $e->getMessage();
447: } while (($e = $e->getPrevious()) !== null);
448:
449: if (count($messages) >= 2 && $messages[0] === 'Typecast parse error') {
450: array_shift($messages);
451: }
452:
453: $errors[$k] = implode(': ', $messages);
454: }
455: }
456: }
457:
458: if (count($errors) > 0) {
459: throw new ValidationException($errors);
460: }
461: }
462:
463: #[\Override]
464: protected function renderView(): void
465: {
466: $this->setupAjaxSubmit();
467: if ($this->controlDisplayRules !== []) {
468: $this->js(true, new JsConditionalForm($this, $this->controlDisplayRules, $this->controlDisplaySelector));
469: }
470:
471: parent::renderView();
472: }
473:
474: #[\Override]
475: protected function renderTemplateToHtml(): string
476: {
477: $output = parent::renderTemplateToHtml();
478:
479: return $this->fixOwningFormAttrInRenderedHtml($output);
480: }
481:
482: public function fixOwningFormAttrInRenderedHtml(string $html): string
483: {
484: return preg_replace_callback('~<(?:button|fieldset|input|output|select|textarea)(?!\w| form=")~i', function ($matches) {
485: return $matches[0] . ' form="' . $this->getApp()->encodeHtml($this->formElement->name) . '"';
486: }, $html);
487: }
488:
489: /**
490: * Set Fomantic-UI Api settings to use with form. A complete list is here:
491: * https://fomantic-ui.com/behaviors/api.html#/settings .
492: *
493: * @param array $config
494: *
495: * @return $this
496: */
497: public function setApiConfig($config)
498: {
499: $this->apiConfig = array_merge($this->apiConfig, $config);
500:
501: return $this;
502: }
503:
504: /**
505: * Set Fomantic-UI Form settings to use with form. A complete list is here:
506: * https://fomantic-ui.com/behaviors/form.html#/settings .
507: *
508: * @param array $config
509: *
510: * @return $this
511: */
512: public function setFormConfig($config)
513: {
514: $this->formConfig = array_merge($this->formConfig, $config);
515:
516: return $this;
517: }
518:
519: public function setupAjaxSubmit(): void
520: {
521: $this->js(true)->form(array_merge([
522: 'on' => 'blur',
523: 'inline' => true,
524: ], $this->formConfig));
525:
526: $this->formElement->js(true)->api(array_merge([
527: 'on' => 'submit',
528: 'url' => $this->cb->getJsUrl(),
529: 'method' => 'POST',
530: 'contentType' => 'application/x-www-form-urlencoded; charset=UTF-8', // remove once https://github.com/jquery/jquery/issues/5346 is fixed
531: 'serializeForm' => true,
532: ], $this->apiConfig));
533:
534: // fix remove prompt for dropdown
535: // https://github.com/fomantic/Fomantic-UI/issues/2797
536: // [name] in selector is to suppress https://github.com/fomantic/Fomantic-UI/commit/facbca003cf0da465af7d44af41462e736d3eb8b console errors from Multiline/vue fields
537: $this->on('change', '.field input[name], .field textarea[name], .field select[name]', $this->js()->form('remove prompt', new JsExpression('$(this).attr(\'name\')')));
538:
539: if (!$this->canLeave) {
540: $this->js(true, (new JsChain('atk.formService'))->preventFormLeave($this->name));
541: }
542: }
543:
544: // }}}
545: }
546: