Skip to content

Create order page

Simon Frost edited this page Nov 11, 2020 · 1 revision

Add custom form fields to the admin create order page

  • Add a new block class which gets the form field logic and does something with it
  • Add a new template which adds the new 'Order Type' section HTML and the RequireJS <script> block which adds the JS onchange observers (see Magento_Sales::order/create/form/account.phtml:21)
  • Add a new block directive to the sales_order_create_load_block_data.xml and sales_order_create_index.xml layouts
    • It should be a child of block name=data (\Magento\Sales\Block\Adminhtml\Order\Create\Data)
    • additional_area is a possible extension point (see Magento_Sales::order/create/data.phtml:19)
  • Extend/mixin html/vendor/magento/module-sales/view/adminhtml/web/order/create/scripts.js:1048 (or create a new JS AMD module) which will define the callback methods we want to execute when the form fields (the order type dropdown and the order ID text input) are changed (see AdminOrder.accountFieldsBind, AdminOrder.accountGroupChange and AdminOrder.accountFieldChange)
  • Add dropdown to MOTO order screen
    • It seems the form fields themselves are added in the \Magento\Sales\Block\Adminhtml\Order\Create\Form\Account::_prepareForm method
  • Add logic to make hidden 'order number' field appear when certain option selected
  • Add order type and order ref fields to order
    • Setup Patch class?
    • We should take performance considerations into account - i.e. If the sales_orders table is very large, any patch/sql could take a long time to run
      • Discuss with Jev/other devs
  • Add logic to validate order number
  • Add logic to save fields
    • Add logic to mark one of these options as selected="selected" (in custom.phtml)
    • Add logic to prevent empty reference order number from being saved
    • Add dedicated table for saving fields to quote
      • Add logic to remove data from order_type_quote table if quote is cancelled
      • Add logic to make sure that only one record ever exists for a quote (i.e. Records should be created and then updated, rather than adding a new record for each edit of the field)
    • Add dedicated table for saving fields to order
    • Add logic to save fields to table with quote_id (i.e. Whilst order is being created)
    • Add logic to save fields to table with order_id (i.e. Once the order has been submitted)
      • Add logic to remove data from order_type_quote table once order has been created
    • Where are these values going to be displayed? Do they need to be displayed anywhere in the admin? In order grids? On manage order pages?
  • Add logic to ensure that custom order type is always populated on frontend and MOTO orders
    • Presumably by setting 'order' as a default value for the column in MySQL
  • Make sure any forms (or data) submitted uses the form key (html/vendor/magento/module-backend/view/adminhtml/templates/admin/formkey.phtml:8)
    • I think Magento automatically adds this to the HTML response

The adminhtml create order screen: How it works

The adminhtml checkout experience displays all the checkout sections at the same time. The usual checkout stages (billing, shipping, payment method, etc) are all refreshed by ajax.

The layout for the different sections are defined in html/vendor/magento/module-sales/view/adminhtml/layout/sales_order_create_*.

Templates are located in Magento_Sales::order/create/*.

Whenever an ajax request is triggered (by changing any form field), all the blocks & templates of the \Magento\Sales\Block\Adminhtml\Order\Create\Data are re-processed and the templates rendered again and returned by the ajax. This means, in practice, that it is not possible to only update a specific section of the adminhtml checkout. This is likely by design, as changing one part of the form (e.g. Shipping address) could affect other parts of the form (e.g. Shipping methods), requiring those sections to be updated as well.

How does the Account Information section work?

Request flow

On the first page load, the page is rendered using the standard Layout XML system, i.e. The \Magento\Sales\Controller\Adminhtml\Order\Create\Index controller and the sales_order_create_index handle are used to render the page and send the response when the http://m23-example-modules.local/admin/sales/order_create/index/key/d4a83114066b28db5289baed58944123e3040d022301fbaa6fb68525c0bd026f/ is loaded for the first time.

On subsequent (ajax) requests, the \Magento\Sales\Controller\Adminhtml\Order\Create\LoadBlock controller is used. This class adds layout handles corresponding to the parameters of the ajax request, then triggers the Layout XML rendering process by explicitly rendering the content container element. This element is defined in sales_order_create_load_block_plain. All the Layout XML handles which are updated by ajax define their blocks under the referenceContainer name="content" node, which ensures that all the parts of the page that need to be updated by ajax, do so.

Ajax requests and Layout XML handles

The ajax requests all hit the same URL, but the layout folder in the Magento_Sales module has dozens of Layout XML files with names like sales_order_create_load_block_billing_address. So, given that a matching URL for this handle doesn't exist, how are the layout updates within applied?

The answer lies in \Magento\Sales\Controller\Adminhtml\Order\Create\LoadBlock::execute. The ajax request triggered by the Account Information fields hits the URL Request URL: http://m23-example-modules.local/admin/sales/order_create/loadBlock/key/4715899381c03de2c04369fc740c072835755eb34a656632bbfa71471a608912/block/data?isAjax=true, which has the parameter /block/data/.

Ajax requests can return either json or plain - this determines the layout handles added to the request, and the controller class return type used.

The controller action method adds a basic layout handle for the page (in this example, sales_order_create_load_block_plain) and additional handle(s) based on the block parameter are added to the request in the format sales_order_create_load_block_<block_value>.

Template rendering

The Account Information form is defined in html/vendor/magento/module-sales/view/adminhtml/layout/sales_order_create_load_block_form_account.xml. (there is no such URL which matches that Layout XML handle though - this is not important now, but will become relevant later, when we explain how the page is updated using ajax requests). The template is defined in Magento_Sales::order/create/form/account.phtml. The block class for the template is \Magento\Sales\Block\Adminhtml\Order\Create\Form\Account. Constructing the form is not done using Layout XML, but rather in pure PHP. The form fields in this section are attributes from the customer entity. The form is initialised in \Magento\Sales\Block\Adminhtml\Order\Create\Form\AbstractForm::getForm. When the block is rendered, \Magento\Framework\Data\FormFactory creates a form object and the abstract method \Magento\Sales\Block\Adminhtml\Order\Create\Form\AbstractForm::_prepareForm is called, which is implemented in \Magento\Sales\Block\Adminhtml\Order\Create\Form\Account.

The _prepareForm method of that class gathers all the required system attributes of the customer entity, then all the user_defined attributes of the customer entity, required or not. If the customer is a guest, the group_id attribute is skipped and not added to the form.

The method then creates a new fieldset and passes both the fieldset and the array of attributes to \ProcessEightAdminhtmlExamples\AddFieldToCreateOrderPageExample\Block\Adminhtml\Order\Create\Form\Custom::_addAttributesToForm.

That method is defined in the parent AbstractForm class. It loops over the array of attributes and adds form fields based on the attribute metadata.

If a field needs custom HTML attributes added, then the \Magento\Sales\Block\Adminhtml\Order\Create\Form\AbstractForm::_addAdditionalFormElementData can be overridden, e.g. \Magento\Sales\Block\Adminhtml\Order\Create\Form\Account::_addAdditionalFormElementData defines extra logic to add validation classes to the email element and to set it as required.

In order to ensure the form fields just added are processed by the right JS, a prefix, order[account] is added to the name attribute of each form field. Finally, the form fields are populated with their values (which is taken care of by the native \Magento\Sales\Block\Adminhtml\Order\Create\Form\Account::extractValuesFromAttributes method).

The rendering of the form fields is triggered by calling the getForm()->getHtml() of the block in the account.phtml template. getForm() is defined in the parent class \Magento\Sales\Block\Adminhtml\Order\Create\Form\AbstractForm::getForm.

Form field renderers

Each element has a renderer assigned to it, based on the attributes' frontend_input type (e.g. input, select, multiselect, multiline, date). Additional input types are defined in the protected method \Magento\Sales\Block\Adminhtml\Order\Create\Form\AbstractForm::_getAdditionalFormElementTypes. If a form field needs custom logic, then the renderer is where that can be encapsulated.

Renderers are block classes which implement the \Magento\Framework\Data\Form\Element\Renderer\RendererInterface and define one method - render, which accepts one argument, \Magento\Framework\Data\Form\Element\AbstractElement.

It is the renderers' responsibility to produce the HTML for a form field. This could be by calling toHtml from it's render method, or by defining the HTML in the same method.

The default renderer for form fields is \Magento\Backend\Block\Widget\Form\Renderer\Element, though this has the intriguing comment of @deprecated 100.2.0 in favour of UI component implementation.

The AbstractForm class defines two more renderers: \Magento\Backend\Block\Widget\Form\Renderer\Fieldset and \Magento\Backend\Block\Widget\Form\Renderer\Fieldset\Element. The latter is also deprecated for the same reason. The only thing which differentiates these three renderers is that they define element-specific templates. Otherwise their render methods just call toHtml to render them.

An interesting example is the region renderer. The \Magento\Customer\Block\Adminhtml\Edit\Renderer\Region::render method defines an element which renders a 'Region' element, which dynamically switches between a select dropdown, and a text input field, based on the country selected.

For an element to be rendered using a specific renderer class, \Magento\Sales\Block\Adminhtml\Order\Create\Form\AbstractForm::_getAdditionalFormElementRenderers method needs to return an array where the key is the attribute to render and the value is an instance of the specific renderer class. See \Magento\Sales\Block\Adminhtml\Order\Create\Form\AbstractForm::_getAdditionalFormElementRenderers for an example.

Additional renderers can be made available to our form by overriding the \Magento\Sales\Block\Adminhtml\Order\Create\Form\AbstractForm::_getAdditionalFormElementRenderers method.

Ajax save logic

The admin create order form is never submitted like a traditional form. Instead, all form fields are submitted using Ajax. Form fields are saved and templates are reloaded using Ajax. There is an inline RequireJS call which instantiates the Magento_Sales/order/create/form JS component module. That component module creates and returns a new order, then back in the template, the AdminOrder.accountFieldsBind method is executed, which binds the onchange events to the callbacks that need to be triggered when the form fields are changed.

AdminOrder.accountFieldsBind, which is defined in html/vendor/magento/module-sales/view/adminhtml/web/order/create/scripts.js:1035, finds all the input, select and textarea fields in the bound element (in this case customer_account_fieds) and then adds an onchange event with one of two callbacks, depending on the form field HTML element type:

  • accountGroupChange observes the Group dropdown. This method is defined in AdminOrder.accountGroupChange.
  • accountFieldChange observes the Email text input. This method is defined in AdminOrder.accountFieldChange.

Triggering of AdminOrder.accountGroupChange method

When an option is selected from the 'Group' dropdown, AdminOrder.accountGroupChange serialises the form order-form_account and then passes it to AdminOrder.loadArea, which submits one of two types of ajax request, depending on the parameters passed into it. AdminOrder.loadArea allows callbacks to be managed aynschronously using jQuery.Deferred(). This object is used to handle the responses from the ajax request in an async fashion.

Side note: jQuery.Deferred()

A factory function that returns a chainable utility object with methods to register multiple callbacks into callback queues, invoke callback queues, and relay the success or failure state of any synchronous or asynchronous function. See https://api.jquery.com/jQuery.Deferred/

Other parameters passed to AdminOrder.loadArea include indicator, which determines whether the ajaxload loading overlay appears and an array containing one (or presumably more) 'areas'. In this case the area passed is data. This data area corresponds to the data block in sales_order_create_index.xml, sales_order_create_load_block_data.xml and the \Magento\Sales\Block\Adminhtml\Order\Create\Data block class and it's template, Magento_Sales::order/create/data.phtml.

Other areas include: items: The order items grid, to add items to an order. search: Search functionality for the order items grid. card_validation: Credit card payment method validation. message: Displays messages.

AdminOrder.loadArea POSTs an ajax request to http://m23-example-modules.local/admin/sales/order_create/loadBlock/key/34dd306dd172bda5bd479269a752f1f7568bc9feb720623bbd4dd6654790f4c3/block/data?isAjax=true. The area param is appended to the end of the ajax URL. If the ajax request is a success, then AdminOrder.loadAreaResponseHandler is called, where one of several things can happen:

  • If the response contains an error message, it is displayed in an alert dialog box.
  • If the response contains a redirect, setLocation performs the redirect
  • If the response does not contain a message property, then it is added to the loadingAreas array (for reasons which will become clear in a moment)
  • If the response contains a header property, it is added to the data-title attribute of the .page-actions-inner HTML element

Finally, for each of the loadingAreas, if the area id is not message then the property of the response matching the area id (e.g. response.data if the area id submitted was data) is injected into the HTML element indicated by the getAreaId method, which simply prepends the string order- onto the existing area id. So data would become order-data, meaning div#order-data in Magento_Sales::order/create/form.phtml has its contents replaced with the data from the ajax response.

Another example. For the Account Information section, the Layout XML name attribute is form_account. The id HTML attribute is hardcoded as order-form_account (in Magento_Sales::order/create/data.phtml). Once the ajax request has succeeded, the AdminOrder.loadAreaResponseHandler method calls AdminOrder.getAreaId, which takes the data parameter passed into AdminOrder.loadArea, resulting in the area id of order-data.

If the area id (order-data in this case) DOM element has a callback, then the callback is called after the DOM is updated.

This request uses the layout XML file html/vendor/magento/module-sales/view/adminhtml/layout/sales_order_create_load_block_data.xml, in which the block form_account is defined. The rendering of this block is triggered by getChildHtml in the data.phtml template, which renders the whole div#page-create-order (in Magento_Sales::order/create/data.phtml). The data.phtml template is rendered initially by html/vendor/magento/module-sales/view/adminhtml/layout/sales_order_create_index.xml, when the page is first loaded, and then by html/vendor/magento/module-sales/view/adminhtml/layout/sales_order_create_load_block_data.xml, whenever a form field which triggers an ajax request is changed (note how the filename is the same as the Layout XML handle for the ajax request).

Triggering of AdminOrder.accountFieldChange method

The AdminOrder.accountFieldChange method works in a very similar way to the AdminOrder.accountGroupChange method, with two main diffferences: The ajaxload overlay never appears and an area is not defined (false is passed instead). An ajax request still takes place onchange, but the logic which updates the page is not triggered (AdminOrder.loadAreaResponseHandler is not called). Any response from this ajax request is totally ignored. The only operation that is executed onSuccess is deferred.resolve, which triggers any callbacks registered on the deferred object to be executed.

Submitting the order

JavaScript

The first thing that happens is that the entire form is validated using JS using the jQuery Validate plugin (http://docs.jquery.com/Plugins/Validation/validate).

If validation is successful, then processStart is triggered. This displays the ajax load overlay.

submitOrder is triggered next. Curiously, this triggers a callback defined in the AdminOrder::initialise method. This callback triggers the execution of the AdminOrder::realOrder method, when the onSubmitOrder event is fired. onRealOrder also has a callback; It binds the form#edit_form element to the AdminOrder::_realSubmit method.

_realSubmit disables the ajax load overlay if there are any validation errors. Otherwise, it triggers the save handler.

http://m23-example-modules.local/static/version1601419254/adminhtml/Magento/backend/en_GB/mage/adminhtml/form.js is eventually called and fires the formSubmit event of the form#edit_form. The form is validated again, using http://m23-example-modules.local/static/version1601419254/adminhtml/Magento/backend/en_GB/jquery/jquery.validate.js. http://m23-example-modules.local/static/version1601419254/adminhtml/Magento/backend/en_GB/mage/backend/validation.js::_submit fires the submit event of form#edit_form.

Control eventually returns to submitOrder, which allows the submission to continue as normal (i.e. be submitted to the server).

Server-side

\Magento\Sales\Controller\Adminhtml\Order\Create\Save::execute is the controller the form submits to. The execute method calls \Magento\Sales\Controller\Adminhtml\Order\Create::_processActionData, just like when editing the order (quote). That method dispatches several events, including adminhtml_sales_order_create_process_data, which is where we subscribe an observer and update the quote_to_order_type table. The quote is saved immediately after the adminhtml_sales_order_create_process_data is dispatched.

Back in \Magento\Sales\Controller\Adminhtml\Order\Create\Save::execute, payment details are updated and the order is created. The $order = $this->quoteManagement->submit($quote, $orderData);, which actually creates the order, is called in \Magento\Sales\Model\AdminOrder\Create::createOrder, which is called in the controller execute method.

We now have our order. Email confirmation is sent, if configured. The checkout_submit_all_after event is dispatched with the order and quote objects. Several modules observe this event and subscribe events to it:

  • Magento\CatalogInventory: 'Subtract qtys of quote item products after multishipping checkout'
    • The same observer is disabled in html/vendor/magento/module-inventory-sales/etc/events.xml:21 with the comment: 'There is no need to register product sale and reindex stock items, as in multi source inventory only reservations are created after order placement'
  • Magento\Paypal: 'Save order into registry to use it in the overloaded controller'.
  • Magento_Authorizenet uses this event to update order increment IDs (This payment method is now deprecated)
  • Magento\Signifyd: 'Creates Signifyd case for single order with online payment method.' (This payment method is also now deprecated)

The session is cleared and the controller redirects to the Manage order page of the admin for the newly created order.