lynx-admin-ui v1.0.0-alpha.67
AdminUI
The AdminUI module automatically generate a user interface to list, view, edit and create new data from tagged entities.
The UI can be personalized and integrated inside other templates, and routes can be protected with middleware.
Beside the user interface, also a CRUD RESTFul API is automatically generated.
How to
AdminUI generates the interface using annotated entities.
An entity shall be annotated with AdminUI
and implement the EditableEntity
interface. Any property of the entity annotated with AdminField
will be available in the interface.
Installation
AdminUI is available on NPM as a Lynx module. It depends on the Datagrid Lynx module, that will be automatically installed by NPM.
npm install lynx-admin-ui --save
In the index.ts
of the application:
import AdminUIModule from "lynx-admin-ui";
import DatagridModule from "lynx-datagrid";
...
const app = new App(myConfig, [new DatagridModule(), new AdminUIModule()] as BaseModule[]);
Documentation
AdminUI
annotation
Usage:
@Entity("categories")
@AdminUI("Category")
export default class Category extends BaseEntity implements EditableEntity {
...
}
Beside the standard Entity
annotation, the AdminUI
one shall be added when an entity is defined. The string
argument indicates a readable name of the entity, that will be used in the UI. Localized string are supported and will be automatically used in the UI.
AdminUI
optional argument
The AdminUI
annotation supports also an optional object argument, with the following optional parameters:
filterBy
defines a function to generate an appropriatewhere
clause used to filter the data in the list section of the AdminUI. It receives the currentreq
request as argument;editorTemplate
andeditorParentTemplate
allow to specify a custom editor template for the current entity;popupEditorTemplate
andpopupEditorParentTemplate
allow to specify a custom popup editor template for the current entity;listTemplate
andlistParentTemplate
allow to specify a custom list template for the current entity;listActionTemplate
allows to specify a custom template to be used as the 'action' column (the last one) in the listing template;backButtonTemplate
allows to specify a custom template to be used as the 'back button' at the top of the edit/details template;listAdditionalActionTemplate
allows to specify a custom template to be used beside the "creation" button on the top of the listing template;listCustomDeleteAction
allows to specify a custom action to be performed instead of the standard delete action; it shall be a named route, that accepts theentityName
andid
path parameters, and theredirect
query parameter;batchDelete
if true (or if resolve to true), checkboxes will be displayed for each list element, enabling a batch delete of elements;relations
allows to specify the name of parameters of the entity (that are relations) that needs to be loaded with the entity (useful in conjunction withdisableReloadOnList
parameter or to filtering/search entity with a particular relation);disableCreation
if true (or if resolve to true), the button to create a new entity is not displayed;disableDelete
if true (or if resolve to true), the button to delete an entity is not displayed;defaultOrderBy
if specified, defines a default order by used in the entity list (it uses the same notation of the ordering string used when a column header is tapped);disableReloadOnList
if true (or if resolve to true), the displayed entities in the list are not reloaded. Please refer to optimization section(#Listing page optimization) for additional info.customFetchData
allows you to create your custom query when retrieving data for the list trough a function that receives the currentreq
as well as datatable infos. If defined, the default query building process will not be executed, meaning yourfilterBy
function will have no effect. Moreover, you need to manually manage the filtering process and the filtering counter (using the(req as any).__admin_ui_filterCounter
attribute).uiSettings
contains information of the visual appearance of the entity views. See theAdminUI uiSettings
paragraph for more information.
Each "template" parameter accepts both a string
, containing the specified path, or a function that accept the current req
request as argument and returns a string
. Using the function version, it is possible to customize a template based on a specific request.
AdminUI uiSettings parameter
This parameter contains information about how the entity view should appear in the interface, and defines the following optional properties:
tabs
: if this property is defined, the detail view of the entity will be split into tabs. This object also define which tabs will be present and their labels. You can assign the entity fields to a tab through their ownuiSettings
property. The object has the following type:{ key: string; label: string }[]
. Each element of the array contains akey
to address the tab, and a ´label`, that will be displayed as the tab title in the view.defaultTab
: is a property of the type:string
, and indicates which tab is the one selected by default. This property value should match thekey
property of one of the defined tabs.tabsAsSections
: if defined and its value istrue
, tabs will be displayed as sections.hasRightColumn
: is a property of the type:boolean
. If the value of this property istrue
, the details view is split in two columns. The second columns will contains the action buttons, like the save button. TheuiSettings
property of each field contains an option to move this field to the right column, just above the action buttons. It could be useful, for instance, to move a "preview image" field to the right column, to highlight the field.hideTabsInExpanded
: is a property of the type:boolean
that decides if the tabs layout is rendered also in the expanded view of the field.hideTabsInModal
: is a property of the type:boolean
that decides if the tabs layout is rendered also in the modal view of the field.smartSearchableHint
: the string to be visualized as hint text for the smart search field (can be localized).noDataString
: indicates a custom localized string to be used when no data is available in the list view.noDataTemplate
: indicates a custom nunjucks template to be used when no data is available in the list view.
EditableEntity
interface
An AdminUI
entity shall also implements the EditableEntity
. To implement the interface, the class shall have these two methods:
getId() {
return this.id;
}
getLabel(): string {
return this.name;
}
The getId
method shall return the primary key of the entity. The getLabel
shall return a readable representation of the entity.
The getId
method is used to find the entity by id, and it can be used to correctly return the corresponding property.
NOTE: if the entity does not implements the EditableEntity
, the AdminUI behavior can be unpredictable.
The EditableEntity
allows also the definition of methods to intercept the life-cycle of the entity:
onBeforeSave
: this method (if implemented) will be executed just BEFORE the saving action of an entity. The entity is already updated with the latest value inserted by the user. If an exception is thrown in this method, the saving process will be interrupted.onAfterSave
: this method (if implemented) will be executed just AFTER the saving action of an entity.
Both methods are executed with the current req
request as argument (that can be used or accessed by this methods to perform additional operations or checks).
AdminField
annotation
The AdminField
annotation indicates that the field should be editable from the AdminUI interface.
The required parameters are the following:
@Column()
@AdminField({ name: "Bio", type: AdminType.RichText })
biography: string;
The name
indicates a readable string for the property (supporting localized strings).
The type
indicates the type of input to be used in the interface, defined by the AdminType
enum.
Other parameters of the AdminField
annotation can be mandatory based on the chosen type.
NOTE: the AdminType
enum is a number. Other custom types can be used instead.
AdminField
other parameters
There is a set of optional parameters, available on all types:
onSummary
: indicates if the field shall appear in the list view; default:false
.searchable
: indicates if the field can be searchable in the list view; default:false
.smartSearchable
: indicates if the field can be searchable, added to a general "string-like" search; default:false
.readOnly
: indicates if the field can be only readable in the editor view; default:false
. This parameter can be aboolean
value, or a function like(req: Request, currentEntity: any) => Promise<boolean>
(same as thevalues
but with different return type).required
: indicates if the field is "required" in the editor view; default:false
. It adds the "required" attribute to the generated input. This parameter can be aboolean
value, or a function like(req: Request, currentEntity: any) => Promise<boolean>
(same as thevalues
but with different return type).hide
: indicates if the field should be displayed or not in the editor view; default:false
. This parameter can be aboolean
value, or a function like(req: Request, currentEntity: any) => Promise<boolean>
(same as thevalues
but with different return type).defaultValue
: allows to define a default value for the field. It can be a value, or a function like(req: Request, currentEntity: any) => Promise<any>
.uiSettings
: contains information on the visual appearance of the field. See theuiSettings
paragraph for more information.optionalParameters
contains optional custom parameters that can be used by the editor.
AdminField
types
AdminType.Id
Indicates that the field is an identifier.
AdminType.String
Indicates that the field is a string. It uses the standard input with type text.
It is possible to specify the pattern
parameters (a string
), in order to perform input validation. This parameter maps the input pattern
attribute.
AdminType.Number
Indicates that the field is a number. It uses the standard input with type number.
It is possible to specify the min
, max
and step
values.
AdminType.Date
Indicates that the field is a date. It uses the standard input with type date.
AdminType.Time
Indicates that the field is a time. It uses the standard input with type time. It is possible to indicates the minimum and the maximum times using the min
and max
parameters (they shall be a string using the 'HH:mm' format).
AdminType.DateTime
Indicates that the field is a date. It uses the standard input with type datetime-local
.
AdminType.Text
Indicates that the field is a long text. It uses the textarea.
AdminType.Selection
Indicates that the field can only have a set of values. It uses the select widget.
It is also necessary to specify the values
parameter.
Example:
const genderValues = [
{ key: Gender.male, value: "Maschio" },
{ key: Gender.female, value: "Femmina" },
{ key: Gender.other, value: "Altro" }
];
...
@Column()
@AdminField({
name: "Gender",
type: AdminType.Selection,
values: genderValues
})
gender: Gender;
...
AdminType.AjaxSelection
Indicates that the field can only have a set of values, dynamically loaded using an ajax request.
It uses the select widget updated with the Select2
library in order to provide a searchability of the options.
This type is intended to be used when the set of values is huge, or/and if options searchability is needed.
It is also necessary to specify the searchRequest
parameter.
Example:
async function filteredCategories(req: Request, currentEntity: any, search: string, page: number): Promise<[{key: any, value: string}[], boolean]> {
let skip = 10 * (page - 1);
let take = 10;
let qb = Category.createQueryBuilder("q");
if (search && search.length > 0) {
qb = qb.where("q.name LIKE :l", { l: '%'+search+'%' });
}
let searched = map(await qb.skip(skip).take(take).getMany());
return [searched, searched.length > 0];
}
...
@ManyToOne(type => Category, { eager: true })
@AdminField({
name: "Categoria con filtro ajax",
type: AdminType.AjaxSelection,
searchRequest: filteredCategories,
onSummary: true,
searchable: true
})
categoryAjax: Category;
AdminType.RichText
Indicates that the field is a long Html text. It uses a RichText editor.
By default, the Quill editor is used.
It is possible to customize the Quill editor options using the optionalParameters
value. A complex rich text editor is available using the defaultRichTextParameters
exported by the lynx-admin-ui/rich-text-options
file. The optionalParameters
shall contains the complete Quill configuration, with the theme
and modules
properties.
AdminType.Checkbox
Indicates that the field can be checked or not, or that the field can have multiple value from a set of values. It uses a single checkbox, or a list of checkboxes. Example with single checkbox:
@Column()
@AdminField({ name: "Accept privacy", type: AdminType.Checkbox })
privacy: boolean;
Example with a list of checkboxes:
@ManyToMany(type => Category, { eager: true })
@JoinTable()
@AdminField({
name: "Other categories",
type: AdminType.Checkbox,
values: getCategories,
selfType: Category
})
subcategories: Category[];
In this particular case, the checkboxes are used to map a many-to-many relationship. The values
parameter is a function (see the values
paragraph) and also the selfType
is specified.
AdminType.Radio
Indicates that the field can only have a set of values. Each value is displayed as a radio button.
It works exactly as the AdminType.Selection
, but it will use the radio buttons as widgets.
AdminType.Table
This type can be used for OneToMany
relations. It allows to display the relationship elements in a table, supporting pagination and column orders.
It works only if the query
parameter is set.
If the max
parameter is specified, the "Add" button will be displayed only if the total number returned by the query
parameter is minor or equal then the max
parameter.
AdminType.Expanded
This type can be used for OneToOne
relations, when the target entity of the relation is available to the AdminUI.
In this case, the fields of the target entity will be available inside the interface of the main entity.
If the readOnly
parameter is specified and, during the request, is resolved to true
, the parameter is automatically inherited by any fields of the expanded entity. Otherwise, the readOnly
parameter of any fields of the expanded entity is used as expected.
The same behavior is applied also for the hide
parameter.
The AdminType.Expanded
supports also the fieldset
optional property. If true
, the input of the target entity will be rendered inside an html fieldset
element.
AdminType.ExpandedAndSelection
This type has the same function of the original AdminType.Expanded
, but also provides the possibility to select the entire entity with a ajax selection (using a similar functionality to AdminType.AjaxSelection
).
For this reason, it is necessary to specify the searchRequest
parameter.
If the readOnly
parameter resolve to true
, both the selection and the editing of any field is not allowed. If the selection can be changed but the "expanded" fields shall be in readonly mode, it is possible to specify the readOnlyExpanded
as true
in the optionalParameters
field.
Moreover, the fieldset
optional property is supported.
AdminType.Color
Indicates that the field is a color. It uses the standard input with type color.
AdminType.Media
Indicate that the field is a media (that needs to be uploaded).
The media is uploaded and saved as a MediaEntity
of the Lynx framework, inside the root directory.
Example:
@ManyToOne(type => MediaEntity, { eager: true })
@JoinColumn()
@AdminField({ name: 'File', type: AdminType.Media, onSummary: false})
file: MediaEntity;
It is possible to specify the accept
parameter, in order to filter the type of file to be uploaded. This parameter maps the input accept
attribute.
AdminType.ActionButton
This special type shall be used with a class method, and not with the classic class attribute.
In the editor page, instead of an input field, a button is displayed. The name
parameter is used as text of the button.
Clicking the button automatically submit the editor form. During the saving process, the corresponding method will be executed.
By default, a btn
class is added to the button widget, but it can be overridden using the property innerEditorClasses
of uiSettings
.
Asynchronous functions are supported
The function is executed with the current
req
Request as first parameter.
Example:
@AdminField({ name: 'I am a BUTTON', type: AdminType.ActionButton, uiSettings: { innerEditorClasses: 'btn btn-warning' }, hide: async (_, k) => k.display })
async actionButton() {
console.log("I'm a function!!");
this.display = true;
}
AdminType.Currency
Indicates that the field is a currency. The implementation uses the Jquery.inputmask plugin to correctly display a mark for decimal and hundreds. Moreover, it is not possible to input characters.
It is possible to override the decimal separator with the decimalSeparator
attribute (.
as default), the hundreds separator with the hundredsSeparator
attribute (,
as default) and the number of decimal numbers with the digits
attribute (2
as default). Both decimalSeparator
and hundredsSeparator
attributes can be a string
, or a function defined as (req: Request, currentEntity: any) => Promise<string>
, where req
is the current request, and currentEntity
is the current displayed entity.
AdminType.CustomInclude
This type can be used to add a custom UI inside the editor view of an entity.
It is possible to create a "dummy" property of the class, without adding a "column" annotation (in order to do not add a column to the real database entity).
A view, defined in the template
of the optionalParameters
object of the field, will be included directly in the editor view, allowing a total customization of the page portion.
Example:
@AdminField({
name: 'Custom element',
type: AdminType.CustomInclude,
optionalParameters: {
template: '/custom-element',
},
})
_customElement: string;
values
parameter
It indicates a list of key-value items that can be used to evaluate the field.
It accepts both a static array, or a function.
The array is defined as { key: any; value: string }[]
.
The function is defined as (req: Request, currentEntity: any) => Promise<{ key: any; value: string }[]>
, where req
is the current request, and currentEntity
is the current displayed entity. If this function is called for the list view, the currentEntity
is an empty object.
selfType
parameter
To work correctly, the AdminUI module needs to know the type of each AdminField
. Most of the times, the module can infer the type automatically. When the type is an array, otherwise, it is necessary to explicitly define the type using the selfType
parameter.
Example:
@ManyToMany(type => Category, { eager: true })
@JoinTable()
@AdminField({
name: "Categories",
type: AdminType.Checkbox,
values: getCategories,
selfType: 'Category'
})
subcategories: Category[];
Since subcategories
is defined as an array of Category
, it is necessary to explicit the selfType
of the single element of the array, that is Category
.
The selfType
is a string
values, so it is necessary to convert the name of the class, for example Category
, to 'Category'
.
NOTE: the Typescript compiler will infer Array
as type of subcategories
.
query
parameter
When the query
parameter is specified, the related field value will be transformed in a Datagrid
object, allowing grid or table visualization inside the editing view.
The query
parameter is defined as a function, that is called as the executor
of a Datagrid
object (please refer to the Datagrid documentation).
Usage:
async function fetchComments(req: Request, entity: Post, params: QueryParams): Promise<[any[], number]> {
return await Comment.findAndCount({
where: {
post: entity,
},
take: params.take,
skip: params.skip,
order: params.order,
});
}
...
@OneToMany((type) => Comment, (comment) => comment.post)
@AdminField({ name: "Comments", type: AdminType.Table, selfType: "Comment", query: fetchComments })
comments: Comment[];
In addition to the usual Datagrid
parameter (third argument), the query
function has also the current req
Request (as first argument), and also the current entity
(as second argument). As depicted in the example, it is possible to use the the entity
to correctly filter the useful data.
If the selfType
of the current object is available as an AdminUI
entity, after the execution of the query
function, the returned data will be automatically cleaned as defined by their annotation (only annotated visible fields will be available in the grid or table view).
searchRequest
parameter
The searchRequest
parameter contains information on how to dynamically obtain options for a particular field.
The searchRequest
parameter shall be an asynchronous function with the following parameters:
req
, the current Lynx request;currentEntity
is the currententity
;search
contains what the user typed for searching (can be empty);page
the current request page (if pagination is supported).
The result of this function shall be a Promise<[{key: any, value: string}[], boolean]>
. The first element of the tupla is the usual key-value array, containing the list of options (the map
method can be used to automatically transform an EditableEntity
to this type). The second element contains info about pagination. If its value is true
, it means that other data can be requested (and other request, with greater page
value, will be delivered). If false
, no further date is available.
uiSettings
parameter
The uiSettings
parameter contains information on how the fields should appear in the editor interface and in the filtering section of the list interface.
By default, both in the editor and in the filtering section, widgets are displayed in the typical 12 column grid system.
This parameter defines the following optional properties:
editorClasses
: indicates custom CSS classes to the widget when displayed in the editor section.innerEditorClasses
: indicates custom CSS classes, internally used by the widget (for example, for theinput
tag). This can be used differently by different types.expandedEditorClasses
: indicates custom CSS classes to the widget when displayed as an expanded element in the editor section.filterClasses
: indicates custom CSS classes to the widget when displayed in the filtering section (default tocol-12
).listTemplate
: indicates a custom template to be used in the list view. The template can access thevalue
variable, containing the current value of the field.listFilter
: indicates a nunjucks filter that should be applied when the element is rendered on the list.additionalEditorInfo
: additional info that can be used by the editor template;any
values accepted.additionalFilterInfo
: additional info that can be used by the editor template;any
values accepted.additionalListInfo
: additional info that can be used by the editor template;any
values accepted.descriptionText
: additionalsmall
text rendered inside thelabel
tag. Supports localized strings.descriptionTextClasses
: custom CSS classes for thedescriptionText
.editorFullWidth
: indicates if the field should occupy the full width of the view in the editor instead of the half width as by default. This option will be overwritten byeditorClasses
.tab
: indicates in which tab the field will be inserted. The tab must be addressed through thekey
property. This property has effect only if thetabs
property of theuiSettings
of theAdminUI
optional parameter is defined and has at least one element. See theAdminUI uiSettings parameter
for further informationonRightColumn
: indicates if the field is rendered in the right column of the detail view. This property should be used only if thehasRightColumn
property of theuiSettings
of theAdminUI
optional parameter is defined astrue
. In the other case, the field will not be rendered.actionListTemplate
: indicates a custom nunjucks template to be used as the last column (the actions) if the specified type isAdminType.Table
.noDataString
: indicates a custom localized string to be used when no data is available (usable in theAdminType.Table
type).noDataTemplate
: indicates a custom nunjucks template to be used when no data is available (usable in theAdminType.Table
type).labelTemplate
: indicates a custom nunjucks template to be used to display the label of the field.
Moreover, in the editor section, each field is wrapped inside a div
with an unique id. The id is defined as field-{{entity-name}}-{{name-of-the-field}}
. Using the id, it is possible to customize though CSS rules the aspect of a single field.
In the list section, each field has its own class, defined as header-{{name-of-the-field}}
and cell-{{header-of-the-field}}
, for the th
and the td
element respectively. These classes apply also for the nested list, used with the AdminType.Table
type.
Utility functions
map
function
The AdminUI module define also a map
function that automatically map an array of EditableEntity
to { key: any; value: string }
. It is defined in the editable-entity
file.
A typical implementation of the values
function could be:
import { map } from "../modules/admin-ui/editable-entity";
async function getCategories() {
return map(await Category.find());
}
that returns the complete list of Category
mapped to the key-value array. Please note that the req
and currentEntity
parameters are omitted.
notEditableFromPopup
function
The notEditableFromPopup
function returns true
when the editing of a entity is requested as a popup.
The popup editing happens then a user add or edit an entity from a relation displayed as a table. In this case, the inverse relation should not be editable inside the popup.
Personalization
Template
AdminUI uses different templates to display the data in different situation. Each template can be customized. Moreover, it possible to customize only the father template of each view, in order to adapt the default layout inside the general theme of the portal. The used templates are the following:
- list template: used to display the main list of the entity;
- editor template: used to edit the entity;
- popup template: used to edit the entity inside a popup;
- nested template: used to display the entity list inside a relation (nested table inside the standard edit view).
For each template, it is available a method, like the AdminUI.setEditorTemplatePath
, to customize the the template path.
To customize the father template, it is possible to use the AdminUI.setEditorParentTemplatePath
methods family.
The only requirement for a father template, is that defines the following blocks:
additional_styles
: is used to add additional CSS resources and styles;additional_scripts
: is used to add additional JS resources and scripts;body
: the section of the page in which display the main content.
This blocks will be used by each templates to correctly load any additional resource, and to display its content.
If you decide to extend the default base layout (eg. to change theme colors by adding a custom css), you can inject different elements to the base layout trough these blocks:
custom_scripts
: is used to inject custom scripts at the end of the bodycustom_styles
: is used to inject custom styles inside the headersidebar_brand
: is used to override the placeholder brandsidebar_nav_items
: is used to fill the sidebar with custom nav itemstopbar_nav_items
: is used to fill the topbar wih custom nav items
CSS Classes
Buttons, fields and tables have additional classes, allowing custom CSS themes. The classes are defined as follows:
btn-create
andbtn-create-{entity-name}
are used for the "Create" button in the list page;table
andtable-{entity-name}
are used for the table in the list page;header-{field-name}
is used on theth
cell of the table in the list page;cell-{field-name}
is used on thetd
cell of the table in the list page;btn-details
andbtn-details-{entity-name}
are used for the "Details" button in the list page;btn-delete
andbtn-delete-{entity-name}
are used for the "Delete" button in the list page;field-{entity-name}-{field-name}
is used to any field in the editing page;btn-save
andbtn-save-{entity-name}
are used for the "Save" button in the editing page;
Custom types
It is possible to add custom types in order to support a larger set of fields.
Use the AdminUI.setEditor
static method to add a new type.
The same method could also be used to use a custom template for an original type.
IMPORTANT: for custom field type, please use numbers greater then 300
, in order to prevent conflict with the original field types.
Permissions and security
It is possible to protect routes and frontend actions by defining logic for reading/writing entities.
Use the respective AdminUI.setCanReadFunction
and AdminUI.setCanWriteFunction
to accomplish this.
The function receives the express request as first parameter and the name of the entity the current action is being performed on as second;
the function must return a boolean value that will be evaluated to determine if che current request has the access rights to
perform the specified action.
You can also use the configured function logic on your templates trough the AUIcanRead
and AUIcanWrite
template functions.
Please note that if non configured any of the routes and frontend actions are not protected.
Low Level API
Starting from version v0.5.0
, a new "low level" API is available.
This API allows to create a custom controller to display and edit the entities.
Thought the API it is possible to customize urls and add custom validation based, for example, on the user role.
Performance optimization
For small-to-medium entities size, the AdminUI provide good performances out of the box. With large entities, or with entities with a lot of eager relations, some adjustments can be necessary.
Listing page optimization
The listing page can have a quite poor performance. This is due to a "reloading" of each displayed entity, in order to ensure the correct visualization of any field and eager relation. This is a convenient behavior, but it can also lead to poor performance.
In this cases, it is better to use the disableReloadOnList
parameter, set to "false"
, and only load the necessary relations with the relations
parameter.
Please refer to ComplexEntity
as an example.
Large values
with AdminType.Selection
When the implementation of the values
returns all the possibility of an entity, there can be performance issues with big dataset.
This problem can be resolved using the AdminType.AjaxSelection
, with an autocomplete field.
If the AdminType.Selection
is used in an "inverted side" of a relation, the AdminType.AjaxSelection
type cannot be used.
In this case, the best option is to inspect the req
parameter, searching for a default value as depicted in the following code:
@AdminField({
type: AdminType.Selection,
name: 'from',
selfType: 'ProductEntity',
readOnly: notEditableFromPopup,
values: async (req, current) => {
let obj = {};
try {
obj = JSON.parse(req.query.defaultValues as string);
} catch (err) {
obj = {
from: req.body.from ?? current?.from?.id,
};
}
return map(await ProductEntity.find({ id: obj.from }));
},
})
@ManyToOne((type) => ProductEntity, (related) => related.related)
from: ProductEntity;
Only the necessary value is retrieved from the database.
2 years ago
2 years ago
1 year ago
1 year ago
1 year ago
1 year ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
2 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
3 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago
4 years ago