hyva-themes / magento2-hyva-admin

This module aims to make creating grids and forms in the Magento 2 adminhtml area joyful and fast.
https://hyva-themes.github.io/magento2-hyva-admin/
BSD 3-Clause "New" or "Revised" License
168 stars 39 forks source link

Forms support #27

Open Vinai opened 3 years ago

Vinai commented 3 years ago

This is a high level issue for documenting design decisions in regards to form support in Hyva_Admin.

Design goals:

Hopefully some of the code and thought that went into the grid design can be reused for forms.

Vinai commented 3 years ago

Initial drafts

Loading and saving entity data:

Every form needs a load and a save declaration.

Repository/Data model:

<load method="\Magento\Customer\Api\CustomerRepositoryInterface::getById"
           type="\Magento\Customer\Api\Data\CustomerInterface">
    <bindArguments>
        <argument name="customer_id" requestParam="id"/>
    </bindArguments>
</load>
<save method="\Magento\Customer\Api\CustomerRepositoryInterface::save">
    <bindArguments>
        <argument name="customer" formData="true"/>
        <argument name="passwordHash" method="\My\Module\Model\CustomerPassword::hash"/>
    </bindArguments>
</save>

ORM Model/Resource model:

<load method="\Magento\Cms\Model\ResourceModel\Block::load" entity="\Magento\Cms\Model\Block">
    <bindArguments>
        <argument name="value" requestParam="id"/>
    </bindArguments>
</load>
<save method="\Magento\Cms\Model\ResourceModel\Block::save"/>

Not all of the elements and attributes above are required. If the entity type can be determined based on the load method return type, it does not have to be declared in the XML.

When saving a form, the submitted data will be used to rehydrate an instance of form entity type, which will then be passed to the save method as the formData argument if specified, or the first argument otherwise. It is possible to configure additional argument bindings on the save and load methods.

Form field declaration

By default, all fields with a getter and setter pair or, for EAV entities, all attributes, will be rendered as input fields. Any fields that have objects as values will be excluded.

This default behavior can then be customized in similar way to grid columns.

<fields>
    <include keepAllSourceFields="true">
        <field name="identifier"/>
        <field name="title" template="My_Module::form/title-field.phtml" joinColumns="true"/>
        <field name="content" type="wysiwyg"/>
        <field name="creation_time" type="datetime"/>
        <field name="is_active" type="boolean" sortOrder="10"/>
        <field name="comment" enabled="false"/>
        <field name="store_ids" type="select" source="\Magento\Eav\Model\Entity\Attribute\Source\Store"/>
        <field name="admin" valueProcessor="\My\Module\Form\AdminLinkProcessor"/>
    </include>
    <exclude>
        <field name="updated_at"/>
    </exclude>
</fields>
Vinai commented 3 years ago

Field type determination

The type determination will probably not be very reliable. Text input fields will be used as a lowest common denominator if no more specific input type can be automatically determined.

Non-scalar values can be handled with a valueProcessor, which can prepare a field value for rendering in a form field, and also can be used to take a submitted value and prepare it for saving (for example, serializing and rehydrating objects).

speedupmate commented 3 years ago

there's also use-case for hidden fields, field comments, field grouping , separators , labels , radio-buttons , submit button labels , submit button actions , ease of providing css, js inclusion paths per form

Vinai commented 3 years ago

@speedupmate can you give a little more info on what you would like to have for "js inclusion paths per form"?

speedupmate commented 3 years ago

The sections in magento2 admin are under single layout handler, I'm not sure how you are approaching this but giving each form or section its in a separate layout handler would allow those forms to be targeted with css and js bit more elegantly

Vinai commented 3 years ago

@speedupmate Thanks for clarifying. Hyva Admin forms will be loaded using layout XML (or as a block in a htmlContent uicomponent) which means they "have" specific layout handles already. They are different from the system.xml forms.

Vinai commented 3 years ago

~JS integration~ [Update in new comment below in thread]

Currently I have the following thoughts on allowing JS integration.

Binding an alpinejs view model

A form can have a x-init and a x-data attributes or child nodes. The content would be placed in the matching attributes on a <div> wrapping the form. If a JS function is referenced in either of the attributes, it can be either specified inline in a separate .phtml template loaded on the page with layout XML, or it could be loaded from a .js file that is loaded in the page with a <script> tag. I'm not sure how to make this properly extensible, or make this work with nested forms (another thing I would like to make possible). Probably most use cases could be covered with this kind of simple initialization though, so maybe it is good enough.

Forms also could have event bindings like obsubmit="...".

Form fields specific JS

I would like to add event specific attributes, for example onclick, ònchange or onkeydown. These would be rendered in the matching attributes on the form fields. For example: onchange="$dispatch('custom-event', { foo: 'bar' })". This would allow intuitive integration with the alpinejs view model.

It would be nice to have a generic event binding attribute. Something liek on="click: open = ! open. (In this example the event and the expression are separated by a colon.)


The above would make it simple and quick to enrich a form with some JS. The downside is that would probably lead to conflicting customizations sooner or later. Thanks to JS dynamic nature it would be possible to resolve these, but still - some pub/sub based system where customizations can be registered without overwriting each other would be more compatible. That said, I do not want to make this a JavaScript framework :)

Any thoughts and insights are very welcome!

Vinai commented 3 years ago

Data Flow

Data needs to flow from the server into the rendered form, and then back again to the server. The shape of this data is determined by the form load and save elements, following a fallback approach.

Loading data into the form

Steps to determine the data shape for rendering the form:

  1. If an entity attribute is specified on the load attribute, the available fields and their properties are determined via reflection. It is possible to use the generic data structures array or \Magento\Framework\DataObject. In these two cases no fields can be extracted from the entity, and all form fields will need to be declared in the XML.

  2. If no entity attribute is specified on the load element, reflection on the load method will be used to determine the entity type. Note: maybe array-of-objects type in the form of Some\Class[] could be implemented to render as a list of forms (use case example: configuring slider content (adding a new slide to existing ones). OTOH, maybe overkill.).

  3. If no entity type can be determined due to lack of type information on the method and no entity attribute is present either, all fields will have to be declared in the forms XML. In this case the load type array will be used as a default.

  4. The information from the fields declared in the forms XML will be merged with the fields determined through reflection in the steps 1 and 2 above.

  5. An event will be dispatched with the form field definitions to allow further conditional customization.

  6. Then the form will be rendered according to the configuration.

Note: different from Hyvä grids, no type information is taken form the loaded data to build the form, only type reflection on the load method and the entity (if present) will be used together with the configured fields. This is because forms also have to work if no data is present.

Saving data from a form

  1. When a form is submitted, all the data is sent to a generic controller and serialized into a PHP array. Hooks and events will be available to provide validation, default values and custom deserialization.

  2. The next step is to determine the target type. The following steps are used to determine the type:

    1. If an entity attribute is present on the save node, it will be used
    2. Otherwise reflection is used to find the type of the first argument of the save method.
    3. If still no target type is found, but an entity type is present on the load element, it will be used.
    4. Finally reflection is used on the load method return type to determine the target type.
    5. If no type information is found or if the type is array, the PHP array is used
  3. The target type is instantiated and populated with the submitted array

  4. The save method is called.

Specific exceptions are used to communicate validation errors.

speedupmate commented 3 years ago

JS integration

I'm not so experienced with alpinejs jet but I think it is enough to leave your objects as x-data="{}" and provide all bindings to events in js implementation , you know like oldschool code and template separation. Otherwise its just like knockoutjs experience with bindings just all over the templates and a nightmare to debug

Since you are defining forms the onsubmit and other event handlers you plan could be implemented/suggested as interface for js implementation to follow (simply put, to implement it you need certain set of methods) ?

And def require js loaded from external file then it can be more easily debugged with developer tools. Adding to templates is easy and all but m2 is still a large project and diff teams need to provide diff features will end up with mixed practices and at some point the experience is not so cool any more.

Vinai commented 3 years ago

Sectioning and Grouping

Currently I think I'll use a similar strategy as is used in the system configuration. In there we have Tabs, Sections and Groups.
However, for Hyva_Admin forms I think it will be better to limit this to two organizing levels: Sections and collapsable Groups.

Fist draft of how it could work:

<sections>
    <section id="foo" label="Foos" sortOrder="10">
        <group id="important-things" sortOrder="10"/>
        <group id="details" sortOrder="20" label="Details"/>
    </section>
    <section id="bar" label="Bars" sortOrder="20">
        <group id="whatever" sortOrder="10"/>
    </section>
</sections>
<fields>
    <include keepAllSourceFields="true">
        <field name="identifier" group="important-things"/>
        <field name="title" template="My_Module::form/title-field.phtml" group="important-things"/>
        <field name="content" type="wysiwyg" group="details"/>
        <field name="creation_time" type="datetime" group="whatever"/>
        <field name="is_active" type="boolean" group="non-existent"/>
        <field name="comment" enabled="false"/>
    </include>
</fields>

For EAV entities, fields can be automatically assigned to groups based on the eav_entity_attribute table.

The following scenarios have to be handled:

  1. No sections are declared, no fields are assigned to groups: render all fields without sections or groups.
  2. No sections are declared, one or more fields have groups: no sections are rendered, just groups using the ids as labels. Fields with no group are collected in a final "other" group.
  3. Sections are declared and a field is assigned to a declared group: render field in that group.
  4. Sections are declared and a field is assigned to an undeclared group: append a new section "Other" to the end of the and assign the field's group to it.
  5. Sections are declared and a field has no group: append field to new section "Other" to new group "other".

Given the above example, the fields would end up in the following hierarchy:

- section: foo
  - group: important-things
    - field: identifier 
    - field: title
  - group: details
    - field: content
- section: bar
  - group: whatever
    - field: creation_time
- section: other
  - group: non-existent
    - field: is_active
  - group: other
    - field: comment

EDIT: replaced tab with section because of the new idea to add generic tabs support to Hyva_Admin.

EDIT 2: Just realized that group IDs will have to be unique after merging so fields can be associated based on the group ID. To document the issue, take this config:

<sections>
    <section id="foo">
        <group id="group1"/>
    </section>
    <section id="bar">
        <group id="group1"/>
    </section>
</sections>
<fields>
    <include>
        <field name="field1" group="group1"/>
    </include>
</fields>

Should this field be associated with foo/group1 or bar/group1? There are three different semi-sensible ways to respond to this problem:

  1. Assign a field to the first or last group with a matching ID.
  2. Throw an exception if groups in different sections have the same ID.
  3. Assign fields by a path like sectionId/groupId, but that introduces another set of issues on how to deal with inconsistencies I would like to avoid.

I think the only sane way to handle this is to throw an exception. Assigning fields to the first or last group with a matching ID would cause surprising and unwanted behavior, that is, fields suddenly moving to another section when a new group is declared.

Vinai commented 3 years ago

JS Integration

My current plan is to allow adding events to fields:

<field name="title">
   <event on="change"/>
   <event on="keydown"/>
</field>

The event name would be built from using the schema hyva_form_[form-name]_[field_name]_[event_name]. For example hyva_form_foo_title_change. The event name is not customizable so there is no chance of accidentally disabling event observers of other modules.

Subscribers receive an object containing the field element and the alpinejs view model along the lines of:

this.$dispatch(event, {field: e.target, viewModel: this});

Forms always dispatches a hyva_form_[form-name]_submit event, it doesn't have to be configured.

Getting the subscribers onto the page is not the responsibility of the form. They can be added using layout XML. If needed this can be extended in future to allow adding JS code to the page directly from the form XML, but initially I think it would be good to keep that out of scope.

Vinai commented 3 years ago

Buttons

By default a form will have a single "Save" button, which posts the form to the build in save controller action. This button can be disabled in the configuration, and additional buttons can be added, too.

First draft:

<navigation>
    <buttons>
        <button id="save" label="Save" url="hyva_admin/form/save" enabled="false" />
        <button id="only-visible-when-entity-was-loaded" label="Example" hiddenForNewEntity="true"/>
        <button id="reset" label="Reset" url="*/*/*"/>
        <button id="validate">
            <event on="click"/>
        </button>
    </buttons>
</navigation>

(The JS integration would be whatever comes out of the discussion and prototyping related to issue #35.)

If the label is omitted, the id attribute is used to generate a label. If a url is omitted, no link will be rendered an any action would have to be preformed via JavaScript.

Any comments and thoughts are welcome as always.

Vinai commented 3 years ago

Entity Relations

One of the most tricky things to model in regards to the forms are entity relations. For example, a way to select the simple products associated with a parent composite product type.

Relations could be handled through embedded grids, multiselects, and so on. I don't think there is a one-size-fits-all solution. Over time I hope that Hyva_Admin forms will offer a couple of options to model relations in a form.

However, in the first prototype release, I will intentionally not implement support for relations. There will be support for select, multiselect, checkbox and radio fields. The options for each of these will be provided by standard Magento source models.

Once the forms architecture settles a bit, then it will be time to revisit entity relation support.

rbouma commented 2 years ago

it's been a while, any updates on the progress?

Vinai commented 2 years ago

Hey @rbouma, thanks for asking! Actually, after a long hiatus where I didn't need to create any backend forms, I now have a need again so I started working on the forms again. I can't give an ETA. I'm still in the "getting back into the code" phase.