Creating your own data grid (table) component
The data grid of Hubleto is the React component Table from @hubleto/react-ui. You rarely write a grid yourself. You configure Table with props and the description from your model.
The simplest table
A Twig view can render a table for any model without any React code:
apps/Deals/Views/Tags.twig
<hblreact-table
int:record-id="{{ viewParams.recordId }}"
string:model="Hubleto/App/Community/Deals/Models/Tag"
></hblreact-table>
A table component of your app
When you need a form on click, filters from the URL or custom behavior, create a component:
apps/Customers/Components/FC/TableCustomers.tsx
import React from 'react'
import Translator from '@hubleto/react-ui/core/Translator';
import Table from '@hubleto/react-ui/components/fc/Table';
import { type TableMeta, type TableProps } from '@hubleto/react-ui/components/fc/TableInterfaces';
import FormCustomer from './FormCustomer';
interface TableCustomersProps extends TableProps {}
const componentName = 'TableCustomers'; // must be the same as the exported const
const parentApp = 'Hubleto/App/Community/Customers';
const T = new Translator(parentApp + '/Loader', 'Components/' + componentName);
const TableCustomers = (props: TableCustomersProps) => {
return <Table
componentName={componentName}
parentApp={parentApp}
model={parentApp + '/Models/Customer'}
baseUrlSlug='customers'
formModalProps={{type: 'right wide'}}
renderCell={(table: TableMeta, columnName: string, column: any, data: any, options: any) => {
if (columnName == "virt_tags") {
return <div className='flex gap-1'>
{data.TAGS.map((tag, key) => {
return <div key={key} className="text-nowrap mr-2">
<i style={{color: tag.TAG?.color}} className="fas fa-tag mr-2"></i>
{tag.TAG?.name}
</div>;
})}
</div>;
} else {
return table.renderDefaultCell(columnName, column, data, options);
}
}}
renderForm={(table: TableMeta): React.JSX.Element => {
return <FormCustomer {...table.getDefaultFormProps()}/>;
}}
{...props}
></Table>
}
export default TableCustomers;
Props of Table
react-ui: components/fc/TableInterfaces.tsx (excerpt)
export interface TableProps {
ref?: any,
componentName?: string,
parentApp?: string,
uid?: string,
description?: TableDescription,
descriptionSource?: 'props' | 'request' | 'both',
recordId?: any,
formDefaultValues?: any,
formEndpoint?: FormEndpoint,
formEndpointParams?: any,
formModalProps?: ModalProps,
formProps?: FormProps,
formActiveTabUid?: any,
formReactComponent?: string,
endpoint?: TableEndpoint,
endpointParams?: any,
model?: string,
parentForm?: any,
tag?: string,
context?: string,
where?: Array<any>,
params?: any,
page?: number,
itemsPerPage?: number,
inlineEditingEnabled?: boolean,
isInlineEditing?: boolean,
isUsedAsInput?: boolean,
selectionMode?: TableSelectionMode,
selection?: Array<any>,
data?: TableData,
async?: boolean,
readonly?: boolean,
title?: string,
closeFormAfterSave?: boolean,
className?: string,
fulltextSearch?: string,
columnSearch?: any,
filters?: any,
view?: string,
invalidInputs?: Array<TableInvalidInput>,
crudController?: string,
junctionTitle?: string,
junctionModel?: string,
junctionSourceColumn?: string,
junctionDestinationColumn?: string,
junctionSourceRecordId?: number,
junctionSaveEndpoint?: string,
baseUrlSlug?: string,
getEndpointParams?: (table: TableMeta) => object,
getEndpointUrl?: (table: TableMeta, action: string) => string,
getCsvImportEndpointParams?: (table: TableMeta) => any,
getSelectionMode?: (table: TableMeta) => any,
getRecordsToDisplay?: (table: TableMeta) => any,
getFormProps?: (table: TableMeta) => any,
getFormModalProps?: (table: TableMeta) => any,
getCellClassName?: (table: TableMeta, columnName: string, column: any, rowData: any) => any,
getCellCssStyle?: (table: TableMeta, columnName: string, column: any, rowData: any) => any,
getMinColumnValue?: (table: TableMeta, columnName: string) => any,
getMaxColumnValue?: (table: TableMeta, columnName: string) => any,
getRowClassName?: (table: TableMeta, rowData: any) => any,
renderAddButton?: (table: TableMeta) => any,
renderMoreActionsButton?: (table: TableMeta) => any,
renderHeaderButtons?: (table: TableMeta) => any,
renderReloadButton?: (table: TableMeta) => any,
renderHeaderLeft?: (table: TableMeta) => any,
renderFulltextSearch?: (table: TableMeta) => any,
renderTitle?: (table: TableMeta) => any,
renderHeaderRight?: (table: TableMeta) => any,
renderHeader?: (table: TableMeta) => any,
renderFilter?: (table: TableMeta) => any,
renderSidebarFilter?: (table: TableMeta) => any,
renderFooter?: (table: TableMeta) => any,
renderDeleteConfirmModal?: (table: TableMeta) => any,
renderFormModal?: (table: TableMeta) => any,
renderForm?: (table: TableMeta) => any,
renderCell?: (table: TableMeta, columnName: string, column: any, data: any, options: any) => any,
renderInsertButton?: (table: TableMeta, row: any) => any,
renderDeleteButton?: (table: TableMeta, row: any) => any,
renderActionsColumn?: (table: TableMeta, row: any) => any,
renderRecordsAsTree?: (table: TableMeta, nodes: any, idParent: number, level: number) => React.JSX.Element,
renderRecords?: (table: TableMeta) => any,
renderContent?: (table: TableMeta) => any,
renderCsvExportForm?: (table: TableMeta) => any,
onAddClick?: (table: TableMeta) => void,
onRowEdited?: (table: TableMeta, input: any, value: any) => void,
onRowClick?: (table: TableMeta, row: any) => void,
onPaginationChange?: (table: TableMeta, page: number, itemsPerPage: number) => void,
onFilterChange?: (table: TableMeta, filterBy: any) => void,
onOrderByChange?: (table: TableMeta, orderBy: TableOrderBy) => void,
onAfterLoadData?: (table: TableMeta) => void,
onAfterLoadDescription?: (table: TableMeta) => void,
}
| Prop | Purpose |
|---|---|
model |
The model that provides the data and the description. |
tag |
A unique name of the table in the page. |
uid |
A unique ID of the component instance. Build it from the uid of the parent. |
parentForm |
The form in which the table is embedded (for related records). |
endpointParams |
Extra parameters for the data endpoint. |
formDefaultValues, formEndpointParams, formReactComponent |
The form that opens when a row is clicked. |
junctionModel, junctionSourceColumn, junctionDestinationColumn, junctionSourceRecordId |
Many-to-many tables. |
filters, where |
Fixed filters of the table. |
view |
The way of rendering rows, e.g. cards. |
readonly, selectionMode, isUsedAsInput |
Behavior. |
onRowClick, onAddClick, onAfterLoadData, onAfterLoadDescription |
Callbacks. |
Frequently used props. The visibility of the header, the footer and the buttons comes from describeTable() ($description->show() and hide()).
Where the data comes from
The table calls generic API controllers:
| Endpoint | Does |
|---|---|
api/table/describe |
Returns describeTable() of the model together with its columns. |
api/record/load-table-data |
Returns the records: loadTableData() of the record manager. |
api/table-describe-and-load |
Both in one request (used by default). |
api/record/save, api/record/delete |
Create, update and delete a record. |
api/record/save-junction |
Save a row of a many-to-many table. |
api/table-export-csv |
CSV export of the table. |
Generic endpoints of the table.
You change what is shown on the server (describeTable(), prepareReadQuery(), addUrlFiltersToQuery()), not in the component:
apps/Deals/Models/RecordManagers/Deal.php (excerpt)
public function addUrlFiltersToQuery(mixed $query): mixed
{
$query = parent::addUrlFiltersToQuery($query);
$hubleto = \Hubleto\Erp\Loader::getGlobalApp();
if ($hubleto->router()->urlParamAsInteger("idCustomer") > 0) {
$query = $query->where("deals.id_customer", $hubleto->router()->urlParamAsInteger("idCustomer"));
}
$filters = $hubleto->router()->urlParamAsArray("filters");
$query = Workflow::applyWorkflowStepFilter(
$this->model,
$query,
(array) ($filters['fDealWorkflowStep'] ?? [])
);
if (isset($filters["fDealSourceChannel"]) && $filters["fDealSourceChannel"] > 0) {
$query = $query->where("deals.source_channel", $filters["fDealSourceChannel"]);
}
if (isset($filters["fDealResult"]) && $filters["fDealResult"] > 0) {
$query = $query->where("deals.deal_result", $filters["fDealResult"]);
}
if (isset($filters["fDealBusinessType"]) && $filters["fDealBusinessType"] > 0) {
$query = $query->where("deals.business_type", $filters["fDealBusinessType"]);
}
if (isset($filters["fDealBusinessType"]) && $filters["fDealBusinessType"] > 0) {
$query = $query->where("deals.business_type", $filters["fDealBusinessType"]);
}
if (isset($filters["fDealOwnership"])) {
switch ($filters["fDealOwnership"]) {
case 1: $query = $query->where("deals.id_owner", $hubleto->authProvider()->getUserId());
break;
case 2: $query = $query->where("deals.id_manager", $hubleto->authProvider()->getUserId());
break;
}
}
$fDealClosed = $filters['fDealClosed'] ?? 1;
if ($fDealClosed == 1) $query = $query->where("deals.is_closed", false);
if ($fDealClosed == 2) $query = $query->where("deals.is_closed", true);
Filtering a related table
To show the contacts of the customer that is open in the form, embed the table of contacts, pass the form as parentForm and the ID as a prop (idCustomer). The table sends it to the API, and the record manager of the contacts applies it in addUrlFiltersToQuery():
apps/Customers/Components/FC/FormCustomer.tsx (excerpt)
<TableContacts
uid={props.uid + "_table_contacts"}
parentForm={form}
view='cards'
idCustomer={form.id}
></TableContacts>
