diff --git a/public/js/form/form-block-builder.js b/public/js/form/form-block-builder.js
index 91a600b..84b464d 100644
--- a/public/js/form/form-block-builder.js
+++ b/public/js/form/form-block-builder.js
@@ -53,8 +53,8 @@ async function setupFormBlock(formCol, formMeta) {
entityData = formData;
}
- if (!entityData && formMeta.entity && entityUId) {
- const getentityResponse = await getfetchEntityData(formMeta.entity, entityUId);
+ if (!entityData && entityUId) {
+ const getentityResponse = await getfetchEntityData(formMeta, entityUId);
entityData = getentityResponse?.data || null;
}
@@ -669,17 +669,28 @@ function sanitizeDomIdSegment(value) {
/**
* Fetch entity data from Laravel API with Sanctum token
*/
-async function getfetchEntityData(entity,entityUId) {
+function resolveFormApiPrefix(formMeta = {}) {
+ return formMeta.business_entity ? 'business-entity' : 'entity';
+}
+
+function resolveFormApiTarget(formMeta = {}) {
+ return formMeta.business_entity || formMeta.entity || null;
+}
+
+async function getfetchEntityData(formMeta, entityUId) {
try {
- if (!entity || !entityUId) {
+ const target = resolveFormApiTarget(formMeta);
+ const prefix = resolveFormApiPrefix(formMeta);
+
+ if (!target || !entityUId) {
return { success: false, error: 'Missing entity or entity UID' };
}
if (typeof apiClient !== 'undefined' && apiClient) {
- return await apiClient.get(`/api/entity/show/${entity}/${entityUId}`);
+ return await apiClient.get(`/api/${prefix}/show/${target}/${entityUId}`);
}
- const res = await fetch(`/api/entity/show/${entity}/${entityUId}`, {
+ const res = await fetch(`/api/${prefix}/show/${target}/${entityUId}`, {
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
@@ -704,24 +715,180 @@ async function getfetchEntityData(entity,entityUId) {
}
function resolveFieldValue(fieldId, formData, entityData) {
- if (formData && Object.prototype.hasOwnProperty.call(formData, fieldId)) {
- return formData[fieldId];
+ const resolvedFormData = entityData?.form_data || formData;
+ const normalizedFieldId = String(fieldId || '').trim();
+
+ if (!normalizedFieldId) {
+ return "";
+ }
+
+ if (resolvedFormData && Object.prototype.hasOwnProperty.call(resolvedFormData, normalizedFieldId)) {
+ return resolvedFormData[normalizedFieldId];
}
if (!entityData) {
return "";
}
- if (Object.prototype.hasOwnProperty.call(entityData, fieldId)) {
- return entityData[fieldId] ?? "";
+ if (Object.prototype.hasOwnProperty.call(entityData, normalizedFieldId)) {
+ return entityData[normalizedFieldId] ?? "";
+ }
+
+ const relationValue = resolveBusinessEntityFieldValue(entityData, normalizedFieldId);
+
+ if (relationValue !== undefined) {
+ return relationValue ?? "";
+ }
+
+ const primaryValue = resolvePrimaryBusinessEntityFieldValue(entityData, normalizedFieldId);
+ if (primaryValue !== undefined) {
+ return primaryValue ?? "";
}
const metaEntries = Array.isArray(entityData.meta) ? entityData.meta : [];
- const metaItem = metaEntries.find((item) => item?.meta_key === fieldId);
+ const metaItem = metaEntries.find((item) => item?.meta_key === normalizedFieldId);
return metaItem ? (metaItem.meta_value ?? "") : "";
}
+function resolveBusinessEntityDisplayValue(entityData, fieldId) {
+ const normalizedFieldId = String(fieldId || '').trim();
+
+ if (!normalizedFieldId || !entityData) {
+ return undefined;
+ }
+
+ const directValue = entityData?.[normalizedFieldId];
+ if (directValue !== undefined) {
+ return directValue;
+ }
+
+ const relationValue = resolveBusinessEntityFieldValue(entityData, normalizedFieldId);
+ if (relationValue !== undefined) {
+ return relationValue;
+ }
+
+ const primaryValue = resolvePrimaryBusinessEntityFieldValue(entityData, normalizedFieldId);
+ if (primaryValue !== undefined) {
+ return primaryValue;
+ }
+
+ return undefined;
+}
+
+function isBusinessEntityResponse(entityData) {
+ return Boolean(
+ entityData?.business_entity ||
+ entityData?.business_entity_name ||
+ entityData?.field_mapping?.primary_entity ||
+ entityData?.field_mapping?.entities
+ );
+}
+
+function resolveBusinessEntityPrimaryKey(entityData) {
+ return entityData?.field_mapping?.primary_entity
+ || entityData?.business_entity_primary_entity
+ || entityData?.field_mapping?.primary
+ || entityData?.field_mapping?.primary_table
+ || null;
+}
+
+function resolvePrimaryBusinessEntityFieldValue(entityData, fieldId) {
+ if (!fieldId) {
+ return undefined;
+ }
+
+ const normalizedFieldId = String(fieldId);
+ const primaryFieldKey = normalizedFieldId.split('_').slice(1).join('_') || normalizedFieldId;
+
+ if (!primaryFieldKey) {
+ return undefined;
+ }
+
+ if (Object.prototype.hasOwnProperty.call(entityData, primaryFieldKey)) {
+ return entityData[primaryFieldKey] ?? "";
+ }
+
+ const metaEntries = Array.isArray(entityData.meta) ? entityData.meta : [];
+ const metaItem = metaEntries.find((item) => item?.meta_key === primaryFieldKey);
+ if (metaItem) {
+ return metaItem.meta_value ?? "";
+ }
+
+ return undefined;
+}
+
+function resolveBusinessEntityFieldValue(entityData, fieldId) {
+ if (!entityData || !fieldId) {
+ return undefined;
+ }
+
+ const segments = String(fieldId).split('_');
+
+ for (let i = segments.length - 1; i >= 1; i--) {
+ const relationKey = segments.slice(0, i).join('_');
+ const leafKey = segments.slice(i).join('_');
+ const relationValue = entityData?.[relationKey];
+
+ if (!relationValue) {
+ continue;
+ }
+
+ if (Array.isArray(relationValue)) {
+ const match = relationValue.find((item) => item && typeof item === 'object' && Object.prototype.hasOwnProperty.call(item, leafKey));
+ if (match) {
+ return match[leafKey] ?? "";
+ }
+
+ const firstItem = relationValue.find((item) => item && typeof item === 'object');
+ if (firstItem && Object.prototype.hasOwnProperty.call(firstItem, leafKey)) {
+ return firstItem[leafKey] ?? "";
+ }
+ }
+
+ if (relationValue && typeof relationValue === 'object') {
+ if (Object.prototype.hasOwnProperty.call(relationValue, leafKey)) {
+ return relationValue[leafKey] ?? "";
+ }
+
+ if (Array.isArray(relationValue.meta)) {
+ const metaItem = relationValue.meta.find((item) => item?.meta_key === leafKey);
+ if (metaItem) {
+ return metaItem.meta_value ?? "";
+ }
+ }
+ }
+ }
+
+ return undefined;
+}
+
+function resolveBusinessEntityFormValue(entityData, fieldId) {
+ const normalizedFieldId = String(fieldId || '').trim();
+
+ if (!normalizedFieldId || !entityData) {
+ return undefined;
+ }
+
+ if (Object.prototype.hasOwnProperty.call(entityData, normalizedFieldId)) {
+ return entityData[normalizedFieldId] ?? "";
+ }
+
+ const relationValue = resolveBusinessEntityFieldValue(entityData, normalizedFieldId);
+ if (relationValue !== undefined) {
+ return relationValue ?? "";
+ }
+
+ const primaryValue = resolvePrimaryBusinessEntityFieldValue(entityData, normalizedFieldId);
+ if (primaryValue !== undefined) {
+ return primaryValue ?? "";
+ }
+
+ const metaEntries = Array.isArray(entityData.meta) ? entityData.meta : [];
+ const metaItem = metaEntries.find((item) => item?.meta_key === normalizedFieldId);
+ return metaItem ? (metaItem.meta_value ?? "") : undefined;
+}
+
/**
*
* Render fetched entity data into form fields
@@ -731,28 +898,60 @@ async function renderEntityDataToForm(formSelector, entityResponse) {
? document.querySelector(formSelector)
: formSelector;
const entityData = entityResponse || null;
+ const formData = entityData?.form_data || null;
+ const formMeta = form?.__formMeta || {};
+ const isBusinessEntityForm = Boolean(formMeta.business_entity);
if (!form || !entityData) return;
- for (const [key, value] of Object.entries(entityData)) {
- const escapedKey = typeof CSS !== 'undefined' && typeof CSS.escape === 'function'
- ? CSS.escape(key)
- : key.replace(/"/g, '\\"');
- const field = form.querySelector(`#${escapedKey}, [name="${escapedKey}"]`);
- const readOnlyField = form.querySelector(`[data-field-id="${escapedKey}"]`);
+ const fieldNodes = Array.from(form.querySelectorAll('input[name], textarea[name], select[name]'));
+ fieldNodes.forEach((fieldNode) => {
+ const fieldName = fieldNode.name || fieldNode.id;
+ if (!fieldName) {
+ return;
+ }
- if (field) {
- if (field.type === 'checkbox') {
- field.checked = Boolean(Number(value)) || value === true || value === '1';
- } else {
- field.value = value ?? "";
- }
+ if (fieldNode.value && fieldNode.value !== '' && fieldNode.value !== 'Enter ' + fieldName.toLowerCase()) {
+ return;
}
- if (readOnlyField) {
- readOnlyField.textContent = value ?? "—";
+ const directValue = formData?.[fieldName] ?? entityData?.[fieldName];
+ const relationValue = isBusinessEntityForm
+ ? resolveBusinessEntityFormValue(entityData, fieldName)
+ : resolveBusinessEntityFieldValue(entityData, fieldName);
+ const metaEntries = Array.isArray(entityData.meta) ? entityData.meta : [];
+ const metaValue = metaEntries.find((item) => item?.meta_key === fieldName)?.meta_value;
+ const resolvedValue = directValue ?? relationValue ?? metaValue;
+
+ if (resolvedValue === undefined || resolvedValue === null || resolvedValue === '') {
+ return;
}
- }
+
+ if (fieldNode.type === 'checkbox') {
+ fieldNode.checked = Boolean(Number(resolvedValue)) || resolvedValue === true || resolvedValue === '1';
+ } else {
+ fieldNode.value = resolvedValue;
+ }
+ });
+
+ const readOnlyNodes = Array.from(form.querySelectorAll('[data-field-id]'));
+ readOnlyNodes.forEach((readOnlyNode) => {
+ const fieldId = readOnlyNode.dataset.fieldId;
+ if (!fieldId) {
+ return;
+ }
+
+ const resolvedValue =
+ formData?.[fieldId] ??
+ entityData?.[fieldId] ??
+ (isBusinessEntityForm ? resolveBusinessEntityFormValue(entityData, fieldId) : resolveBusinessEntityFieldValue(entityData, fieldId));
+
+ if (resolvedValue === undefined || resolvedValue === null || resolvedValue === '') {
+ return;
+ }
+
+ readOnlyNode.textContent = resolvedValue;
+ });
const metaEntries = Array.isArray(entityData.meta) ? entityData.meta : [];
metaEntries.forEach((item) => {
diff --git a/public/js/form/form.js b/public/js/form/form.js
index 5028a64..2a6500d 100644
--- a/public/js/form/form.js
+++ b/public/js/form/form.js
@@ -728,17 +728,19 @@ async function renderFormForModal(formElement, formMeta) {
const entityUId = formElement.dataset.entityUid;
let entityData = formElement.__entityDataCache || null;
+ let formDataFromSchema = null;
if (!entityData && formData && typeof formData === 'object' && !Array.isArray(formData)) {
entityData = formData;
}
- if (!entityData && formMeta.entity && entityUId) {
- const getentityResponse = await getfetchEntityData(formMeta.entity, entityUId);
+ if (!entityData && entityUId) {
+ const getentityResponse = await getfetchEntityData(formMeta, entityUId);
entityData = getentityResponse?.data || null;
}
if (entityData) {
+ formDataFromSchema = entityData.form_data || null;
formElement.__entityDataCache = entityData;
}
@@ -746,7 +748,7 @@ async function renderFormForModal(formElement, formMeta) {
formMeta.fields.forEach((field, index) => {
field._renderIndex = index;
field._domId = buildFieldDomId(formMeta, field, index);
- field.value = resolveFieldValue(field.id, formData, entityData);
+ field.value = resolveFieldValue(field.id, formDataFromSchema || formData, entityData);
addField(field, formElement, formMeta);
});
}
@@ -1199,25 +1201,34 @@ function resolveDynamicFormMethod(formMeta, form) {
}
function resolveDynamicFormEndpoint(formMeta, form, method) {
+ const apiPrefix = formMeta.business_entity ? 'business-entity' : 'entity';
+ const apiTarget = formMeta.business_entity || formMeta.entity || null;
+
if (formMeta.endpoint) {
+ if (formMeta.business_entity) {
+ return String(formMeta.endpoint)
+ .replace('/api/entity/', '/api/business-entity/')
+ .replace('/api/entity', '/api/business-entity');
+ }
+
return formMeta.endpoint;
}
- if (!formMeta.entity) {
+ if (!apiTarget) {
return null;
}
const entityUid = form.dataset.entityUid;
if ((method === 'PUT' || method === 'PATCH') && entityUid) {
- return `/api/entity/update/${formMeta.entity}/${entityUid}`;
+ return `/api/${apiPrefix}/update/${apiTarget}/${entityUid}`;
}
if (method === 'DELETE' && entityUid) {
- return `/api/entity/delete/${formMeta.entity}/${entityUid}`;
+ return `/api/${apiPrefix}/delete/${apiTarget}/${entityUid}`;
}
- return `/api/entity/store/${formMeta.entity}`;
+ return `/api/${apiPrefix}/store/${apiTarget}`;
}
function serializeDynamicFormPayload(form, formMeta) {
diff --git a/public/js/table/table-constants.js b/public/js/table/table-constants.js
index ffa0048..03cf9d6 100644
--- a/public/js/table/table-constants.js
+++ b/public/js/table/table-constants.js
@@ -37,6 +37,7 @@ const DETAIL_RENDER_MODE_FORM = 'form';
const TABLE_COMPONENT_LAB_FORM = 'userinterface::components.lab-form';
const TABLE_API_ENTITY_LIST_PREFIX = '/api/entity/list/';
+const TABLE_API_BUSINESS_ENTITY_LIST_PREFIX = '/api/business-entity/list/';
const TABLE_API_COMPONENT_TEMPLATE_PREFIX = '/api/components/templates/';
const TABLE_SELECTOR_LAB_TABLE = '.lab-table';
diff --git a/public/js/table/table-inbox-view.js b/public/js/table/table-inbox-view.js
index c2afe49..6058436 100644
--- a/public/js/table/table-inbox-view.js
+++ b/public/js/table/table-inbox-view.js
@@ -1,7 +1,7 @@
-function clearInboxRowSelection(tableElement) {
- if (!tableElement) {
- return;
- }
+function clearInboxRowSelection(tableElement) {
+ if (!tableElement) {
+ return;
+ }
tableElement.querySelectorAll(TABLE_SELECTOR_SELECTED_ROW).forEach((row) => {
row.classList.remove(TABLE_CLASS_BG_PRIMARY_SUBTLE);
@@ -10,11 +10,32 @@ function clearInboxRowSelection(tableElement) {
});
});
}
-
-function setInboxRowSelection(rowElement) {
- if (!rowElement) {
- return;
- }
+
+function syncInboxRowSelection(tableElement) {
+ if (!tableElement) {
+ return;
+ }
+
+ clearInboxRowSelection(tableElement);
+
+ tableElement.querySelectorAll(`${TABLE_SELECTOR_ROW_CHECKBOX}:checked`).forEach((checkbox) => {
+ const rowElement = checkbox.closest('tr');
+
+ if (!rowElement) {
+ return;
+ }
+
+ rowElement.classList.add(TABLE_CLASS_BG_PRIMARY_SUBTLE);
+ rowElement.querySelectorAll(TABLE_SELECTOR_TABLE_CELLS).forEach((cell) => {
+ cell.classList.add(TABLE_CLASS_BG_PRIMARY_SUBTLE);
+ });
+ });
+}
+
+function setInboxRowSelection(rowElement) {
+ if (!rowElement) {
+ return;
+ }
const tableElement = rowElement.closest(TABLE_SELECTOR_TABLE);
clearInboxRowSelection(tableElement);
@@ -48,7 +69,7 @@ function styleInboxRows(tableElement) {
// ---------------------------
// 📧 INBOX VIEW RENDERING
// ---------------------------
-function renderInboxView(tableElement, cache, dtConfig, entityName, schema, targetParent = null) {
+function renderInboxView(tableElement, cache, dtConfig, entityName, schema, targetParent = null) {
console.log(TABLE_LOG_RENDER_INBOX_VIEW, entityName);
const { columns = [] } = dtConfig;
@@ -122,16 +143,16 @@ function renderInboxView(tableElement, cache, dtConfig, entityName, schema, targ
.replace(/_/g, " ")
.replace(/\b\w/g, c => c.toUpperCase());
- listTable.innerHTML = `
-
-
+ listTable.innerHTML = `
+
+
|
-
- |
+
+
- ${formattedEntityName}
- |
-
+ ${formattedEntityName}
+
+
`;
@@ -176,42 +197,49 @@ function renderInboxView(tableElement, cache, dtConfig, entityName, schema, targ
},
ajax: (params, callback) =>
handleAjaxFetch(params, callback, cache, entityName, listTable, schema),
- initComplete: function (...args) {
- if (typeof dtConfig.initComplete === 'function') {
- dtConfig.initComplete.apply(this, args);
- }
-
- styleInboxRows(listTable);
- applyInboxStickyStyles(leftPanel);
- initializeInboxSummaryComponents(listTable);
- },
- drawCallback: function (...args) {
- if (typeof dtConfig.drawCallback === 'function') {
- dtConfig.drawCallback.apply(this, args);
- }
-
- styleInboxRows(listTable);
- applyInboxStickyStyles(leftPanel);
- initializeInboxSummaryComponents(listTable);
- },
- });
-
- styleInboxRows(listTable);
- applyInboxStickyStyles(leftPanel);
- initializeInboxSummaryComponents(listTable);
+ initComplete: function (...args) {
+ if (typeof dtConfig.initComplete === 'function') {
+ dtConfig.initComplete.apply(this, args);
+ }
+
+ styleInboxRows(listTable);
+ applyInboxStickyStyles(leftPanel);
+ initializeInboxSummaryComponents(listTable);
+ syncInboxRowSelection(listTable);
+ },
+ drawCallback: function (...args) {
+ if (typeof dtConfig.drawCallback === 'function') {
+ dtConfig.drawCallback.apply(this, args);
+ }
+
+ styleInboxRows(listTable);
+ applyInboxStickyStyles(leftPanel);
+ initializeInboxSummaryComponents(listTable);
+ syncInboxRowSelection(listTable);
+ },
+ });
- // Setup resizer
- setupResizer(resizer, leftPanel, rightPanelEle, container);
+ styleInboxRows(listTable);
+ applyInboxStickyStyles(leftPanel);
+ initializeInboxSummaryComponents(listTable);
+ syncInboxRowSelection(listTable);
+
+ // Setup resizer
+ setupResizer(resizer, leftPanel, rightPanelEle, container);
// Setup checkbox handlers
setupCheckboxHandlers(listTable, true);
console.log(`${TABLE_LOG_INBOX_VIEW_READY} ${entityName}`);
- // Handle row selection
+ // Handle row selection
$(listTable).on('click', TABLE_SELECTOR_INBOX_ROW, function(e) {
- // Don't trigger if clicking checkbox
- if ($(e.target).hasClass(TABLE_SELECTOR_ROW_CHECKBOX.slice(1)) || $(e.target).closest(TABLE_SELECTOR_CHECKBOX_COLUMN).length) {
+ // Don't trigger if clicking checkbox or the responsive/detail toggle control
+ if (
+ $(e.target).hasClass(TABLE_SELECTOR_ROW_CHECKBOX.slice(1)) ||
+ $(e.target).closest(TABLE_SELECTOR_CHECKBOX_COLUMN).length ||
+ $(e.target).closest('button, a, .dtr-control, .dt-control, .details-control').length
+ ) {
return;
}
@@ -220,11 +248,11 @@ function renderInboxView(tableElement, cache, dtConfig, entityName, schema, targ
}
const data = dt.row(this).data();
- if (data) {
- loadDetailComponent(rightPanelEle, schema, data);
- setInboxRowSelection(this);
- }
- });
+ if (data) {
+ loadDetailComponent(rightPanelEle, schema, data);
+ setInboxRowSelection(this);
+ }
+ });
console.log(`${TABLE_LOG_INBOX_VIEW_FULLY_READY} ${entityName}`);
diff --git a/public/js/table/table-init.js b/public/js/table/table-init.js
index f448065..bd62b86 100644
--- a/public/js/table/table-init.js
+++ b/public/js/table/table-init.js
@@ -222,17 +222,17 @@ async function initLabTable(tableElement) {
if (!schemaResponse.success || !schemaResponse.data) {
removeTableSkeleton(tableElement);
return showErrorMessage(tableElement, schemaResponse.message || TABLE_MESSAGE_TABLE_SCHEMA_NOT_FOUND);
- }
-
+ }
+
const schema = schemaResponse.data;
- const entity = schema.entity;
+ const entity = schema.entity || schema.business_entity || null;
const dtSchemaConfig = schema["dt-options"] || {};
const entriesPerPage = schema.entries_per_page || 10;
if (!entity || !dtSchemaConfig.columns) {
removeTableSkeleton(tableElement);
return showErrorMessage(tableElement, "Invalid schema or missing entity");
- }
+ }
// Initialize cache
const cache = new EntityCache(entity, entriesPerPage);
diff --git a/public/js/table/table-providers.js b/public/js/table/table-providers.js
index 8209781..910cfa0 100644
--- a/public/js/table/table-providers.js
+++ b/public/js/table/table-providers.js
@@ -9,8 +9,17 @@ function removeTableSkeleton(tableElement) {
tableElement.classList.remove(TABLE_CLASS_HIDDEN);
}
-async function fetchEntityData(entity, offset = 0, length = 50) {
- const response = await apiClient.get(`${TABLE_API_ENTITY_LIST_PREFIX}${entity}`, {
+function resolveTableListApiPrefix(schema = {}) {
+ if (schema?.business_entity || schema?.is_business_entity) {
+ return TABLE_API_BUSINESS_ENTITY_LIST_PREFIX;
+ }
+
+ return TABLE_API_ENTITY_LIST_PREFIX;
+}
+
+async function fetchEntityData(entity, offset = 0, length = 50, schema = {}) {
+ const listApiPrefix = resolveTableListApiPrefix(schema);
+ const response = await apiClient.get(`${listApiPrefix}${entity}`, {
offset,
length
});
diff --git a/public/js/table/table-renderers.js b/public/js/table/table-renderers.js
index 32454f6..73e3f74 100644
--- a/public/js/table/table-renderers.js
+++ b/public/js/table/table-renderers.js
@@ -27,27 +27,21 @@ function applyCompactDataTableControls(container) {
/**
* Render inbox row with "Label: Value" in one line (Bootstrap only)
*/
-function renderFallbackInboxRow(row, columns) {
- const visibleColumns = columns.filter(
- col =>
- col.visible !== false &&
- col.data &&
- !col.data.startsWith('meta.')
- );
+function renderFallbackInboxRow(row, columns, schema = {}) {
+ const allowMetaColumns = !!(schema?.business_entity || schema?.is_business_entity);
+ const visibleColumns = columns.filter(
+ col =>
+ col.visible !== false &&
+ col.data &&
+ (allowMetaColumns || !col.data.startsWith('meta.'))
+ );
return `
${visibleColumns
.map(col => {
- let value = '';
-
- if (col.data.includes('.')) {
- value = col.data
- .split('.')
- .reduce((acc, key) => acc?.[key], row) ?? '';
- } else {
- value = row[col.data] ?? '';
- }
+ let value = '';
+ value = resolveRowValueByPath(row, col.data);
if (value === '' || value === null || value === undefined) {
return '';
@@ -110,17 +104,15 @@ function getRowMetaMap(row = {}) {
}, {});
}
-function getFirstRowValue(row = {}, metaMap = {}, paths = [], fallback = '') {
- for (const path of paths) {
- let value = '';
-
- if (path.startsWith('meta:')) {
- value = metaMap[path.slice(5)];
- } else if (path.includes('.')) {
- value = path.split('.').reduce((acc, key) => acc?.[key], row);
- } else {
- value = row?.[path];
- }
+function getFirstRowValue(row = {}, metaMap = {}, paths = [], fallback = '') {
+ for (const path of paths) {
+ let value = '';
+
+ if (path.startsWith('meta:')) {
+ value = metaMap[path.slice(5)];
+ } else {
+ value = resolveRowValueByPath(row, path);
+ }
if (value !== null && value !== undefined && value !== '') {
return value;
@@ -265,8 +257,8 @@ function hydrateComponentTemplate(templateHtml, bindings = {}, row = {}) {
function renderInboxRow(row, columns, schema = {}) {
const summaryComponent = getSchemaConfigValue(schema, TABLE_SCHEMA_KEY_SUMMARY_COMPONENT);
if (!summaryComponent) {
- return renderFallbackInboxRow(row, columns);
- }
+ return renderFallbackInboxRow(row, columns, schema);
+ }
if (schema.__summaryComponentTemplate?.html) {
return `
@@ -280,19 +272,20 @@ function renderInboxRow(row, columns, schema = {}) {
`;
}
- return renderFallbackInboxRow(row, columns);
-}
-
-
-function getPriorityColumns(columns) {
- const priorityOrder = ['id', 'status', 'name', 'email', 'title', 'subject'];
-
- return columns
- .filter(c => c.visible !== false)
- .filter(c => !c.data?.startsWith?.('meta.'))
- .sort((a, b) => {
- const aIdx = priorityOrder.indexOf(a.data);
- const bIdx = priorityOrder.indexOf(b.data);
+ return renderFallbackInboxRow(row, columns, schema);
+}
+
+
+function getPriorityColumns(columns, schema = {}) {
+ const allowMetaColumns = !!(schema?.business_entity || schema?.is_business_entity);
+ const priorityOrder = ['id', 'status', 'name', 'email', 'title', 'subject'];
+
+ return columns
+ .filter(c => c.visible !== false)
+ .filter(c => allowMetaColumns || !c.data?.startsWith?.('meta.'))
+ .sort((a, b) => {
+ const aIdx = priorityOrder.indexOf(a.data);
+ const bIdx = priorityOrder.indexOf(b.data);
if (aIdx === -1 && bIdx === -1) return 0;
if (aIdx === -1) return 1;
if (bIdx === -1) return -1;
@@ -326,6 +319,48 @@ function getLoaderComponentHTML() {
`;
}
+
+function resolveRowValueByPath(row = {}, path = '') {
+ if (!path) {
+ return '';
+ }
+
+ if (!path.includes('.')) {
+ return row?.[path] ?? '';
+ }
+
+ const segments = path.split('.');
+ const [first, second, third] = segments;
+
+ if (first && first !== 'meta' && row?.[first] && segments.length > 1) {
+ const nestedValue = segments.reduce((acc, key) => acc?.[key], row);
+ if (nestedValue !== undefined && nestedValue !== null && nestedValue !== '') {
+ return nestedValue;
+ }
+ }
+
+ if (second === 'meta') {
+ const metaKey = third || '';
+ const metaValue = row?.meta?.[metaKey] ?? row?.[first]?.meta?.[metaKey];
+ if (metaValue !== undefined && metaValue !== null && metaValue !== '') {
+ return metaValue;
+ }
+
+ return '';
+ }
+
+ const directValue = segments.reduce((acc, key) => acc?.[key], row);
+ if (directValue !== undefined && directValue !== null && directValue !== '') {
+ return directValue;
+ }
+
+ const strippedPath = segments.length > 1 ? segments.slice(1).join('.') : path;
+ if (strippedPath && strippedPath !== path) {
+ return resolveRowValueByPath(row, strippedPath);
+ }
+
+ return '';
+}
function escapeDetailHtml(value) {
return String(value)
@@ -358,9 +393,9 @@ function formatFallbackDetailValue(value) {
return escapeDetailHtml(value);
}
-function renderFallbackDetailComponent(data = {}) {
- const rows = [];
- const recordEntries = Object.entries(data).filter(([key]) => key !== 'meta');
+function renderFallbackDetailComponent(data = {}) {
+ const rows = [];
+ const recordEntries = Object.entries(data).filter(([key]) => key !== 'meta');
recordEntries.forEach(([key, value]) => {
rows.push(`
@@ -371,18 +406,29 @@ function renderFallbackDetailComponent(data = {}) {
`);
});
- const metaEntries = Array.isArray(data.meta) ? data.meta : [];
- metaEntries.forEach((item) => {
- const metaKey = item?.meta_key || item?.key || 'Meta';
- const metaValue = item?.meta_value ?? item?.value ?? item;
-
- rows.push(`
-
- | ${escapeDetailHtml(`M / ${formatDetailLabel(metaKey)}`)} |
- ${formatFallbackDetailValue(metaValue)} |
-
- `);
- });
+ const metaValue = data.meta;
+ if (Array.isArray(metaValue)) {
+ metaValue.forEach((item) => {
+ const metaKey = item?.meta_key || item?.key || 'Meta';
+ const value = item?.meta_value ?? item?.value ?? item;
+
+ rows.push(`
+
+ | ${escapeDetailHtml(`M / ${formatDetailLabel(metaKey)}`)} |
+ ${formatFallbackDetailValue(value)} |
+
+ `);
+ });
+ } else if (metaValue && typeof metaValue === 'object') {
+ Object.entries(metaValue).forEach(([metaKey, value]) => {
+ rows.push(`
+
+ | ${escapeDetailHtml(`M / ${formatDetailLabel(metaKey)}`)} |
+ ${formatFallbackDetailValue(value)} |
+
+ `);
+ });
+ }
if (rows.length === 0) {
rows.push(`
@@ -392,7 +438,7 @@ function renderFallbackDetailComponent(data = {}) {
`);
}
- return `
+ return `
${TABLE_MESSAGE_RECORD_INFO}
@@ -1004,11 +1050,13 @@ async function loadDetailComponent(rightPanelEle, schema, data, options = {}) {
appendHybridDetailActions(rightPanelEle, contentContainer, detailContext);
}
- if (typeof setupCoreFormElement === 'function') {
- setupCoreFormElement();
- }
-
- initializeDetailViewScripts(contentContainer);
+ if (typeof setupCoreFormElement === 'function') {
+ setupCoreFormElement();
+ }
+
+ injectMetaDetailsIfMissing(contentContainer, data);
+
+ initializeDetailViewScripts(contentContainer);
console.log(`${TABLE_LOG_DETAIL_COMPONENT_LOADED} ${data.uid}`);
rendered = true;
@@ -1048,6 +1096,71 @@ function showDetailError(container, message) {
`;
}
+
+function renderMetaDetailRows(data = {}) {
+ const metaValue = data?.meta;
+ if (!metaValue) {
+ return '';
+ }
+
+ const rows = [];
+
+ if (Array.isArray(metaValue)) {
+ metaValue.forEach((item) => {
+ const metaKey = item?.meta_key || item?.key || 'Meta';
+ const value = item?.meta_value ?? item?.value ?? item;
+
+ rows.push(`
+
+ | ${escapeDetailHtml(`M / ${formatDetailLabel(metaKey)}`)} |
+ ${formatFallbackDetailValue(value)} |
+
+ `);
+ });
+ } else if (typeof metaValue === 'object') {
+ Object.entries(metaValue).forEach(([metaKey, value]) => {
+ rows.push(`
+
+ | ${escapeDetailHtml(`M / ${formatDetailLabel(metaKey)}`)} |
+ ${formatFallbackDetailValue(value)} |
+
+ `);
+ });
+ }
+
+ if (!rows.length) {
+ return '';
+ }
+
+ return `
+
+
+
+
+ ${rows.join('')}
+
+
+
+ `;
+}
+
+function injectMetaDetailsIfMissing(contentContainer, data = {}) {
+ if (!contentContainer || !data?.meta) {
+ return;
+ }
+
+ const hasRenderedMeta = contentContainer.textContent?.includes('M /');
+ if (hasRenderedMeta) {
+ return;
+ }
+
+ const metaHtml = renderMetaDetailRows(data);
+ if (!metaHtml) {
+ return;
+ }
+
+ contentContainer.insertAdjacentHTML('beforeend', metaHtml);
+}
function initializeInboxSummaryComponents(container) {
const summaryNodes = container.querySelectorAll(TABLE_SELECTOR_SUMMARY_NODES);
diff --git a/public/js/table/table-runtime.js b/public/js/table/table-runtime.js
index 4a76362..a651e14 100644
--- a/public/js/table/table-runtime.js
+++ b/public/js/table/table-runtime.js
@@ -88,11 +88,12 @@ function renderLazyDataTable(tableElement, cache, dtConfig, entityName) {
tableElement.innerHTML = `
`;
const theadRow = tableElement.querySelector(TABLE_SELECTOR_THEAD_ROWS);
- // Add checkbox column header
+ // Add checkbox column header
const checkboxTh = document.createElement("th");
checkboxTh.className = TABLE_SELECTOR_CHECKBOX_COLUMN.slice(1);
- checkboxTh.innerHTML = ``;
- theadRow.appendChild(checkboxTh);
+ checkboxTh.style.width = TABLE_VALUE_CHECKBOX_COLUMN_WIDTH;
+ checkboxTh.innerHTML = ``;
+ theadRow.appendChild(checkboxTh);
// Add other columns
columns.forEach(col => {
@@ -160,24 +161,46 @@ function renderLazyDataTable(tableElement, cache, dtConfig, entityName) {
console.log(`${TABLE_LOG_DATATABLE_READY} ${entityName}`);
}
-function renderCell(col, row, rootFormSchemaUid) {
- let val = "";
- if (col.data?.startsWith?.("meta.")) {
- const key = col.data.split(".")[1];
- const metaItem = (row.meta || []).find(m => m.meta_key === key);
- val = metaItem ? metaItem.meta_value : "";
- } else if (col.data?.includes?.(".")) {
- val = col.data.split(".").reduce((acc, key) => acc?.[key], row) ?? "";
- } else {
- val = row[col.data] ?? "";
- }
+function renderCell(col, row, rootFormSchemaUid) {
+ let val = "";
+ if (col.data?.startsWith?.("meta.")) {
+ const key = col.data.split(".")[1];
+ const metaItem = (row.meta || []).find(m => m.meta_key === key);
+ val = metaItem ? metaItem.meta_value : "";
+ } else {
+ val = resolveRowValueByPath(row, col.data);
+ }
if (col.link && row.uid) {
const formSchemaUid = col["form-schema-uid"] || rootFormSchemaUid;
return `${val}`;
}
- return val;
-}
+ return val;
+}
+
+function resolveRowValueByPath(row = {}, path = '') {
+ if (!path) {
+ return '';
+ }
+
+ if (!path.includes('.')) {
+ return row?.[path] ?? '';
+ }
+
+ const segments = path.split('.');
+ const [first, second, third] = segments;
+
+ if (second === 'meta') {
+ return row?.meta?.[third] ?? row?.[first]?.meta?.[third] ?? '';
+ }
+
+ const directValue = segments.reduce((acc, key) => acc?.[key], row);
+ if (directValue !== undefined && directValue !== null && directValue !== '') {
+ return directValue;
+ }
+
+ return resolveRowValueByPath(row, segments.slice(1).join('.'));
+}
// ---------------------------
// 🔁 ENHANCED AJAX FETCH HANDLER
@@ -217,7 +240,7 @@ async function handleAjaxFetch(params, callback, cache, entityName, tableElement
console.log(TABLE_LOG_CACHE_MISS);
showTableLoader(tableElement);
- const result = await fetchEntityData(entityName, start, length);
+ const result = await fetchEntityData(entityName, start, length, schema);
if (result.success && result.data) {
cache.set(start, result.data, result.total);
@@ -256,10 +279,10 @@ async function prefetchNextBatch(cache, entityName, currentStart, currentLength,
console.log(`${TABLE_LOG_PREFETCHING} ${nextOffset}`);
- const prefetchPromise = fetchEntityData(entityName, nextOffset, currentLength)
- .then(result => {
- if (result.success && result.data?.length) {
- cache.set(nextOffset, result.data, result.total);
+ const prefetchPromise = fetchEntityData(entityName, nextOffset, currentLength, schema)
+ .then(result => {
+ if (result.success && result.data?.length) {
+ cache.set(nextOffset, result.data, result.total);
console.log(`${TABLE_LOG_PREFETCH_COMPLETE} ${result.data.length} rows`);
}
})
@@ -325,20 +348,39 @@ function showErrorMessage(table, msg) {
// ---------------------------
// ✅ CHECKBOX HANDLERS
// ---------------------------
-function setupCheckboxHandlers(tableElement, isInboxView = false) {
+function setupCheckboxHandlers(tableElement, isInboxView = false) {
const selectAllId = isInboxView ? TABLE_ID_SELECT_ALL_INBOX : TABLE_ID_SELECT_ALL;
- const selectAllCheckbox = document.getElementById(selectAllId);
-
- if (!selectAllCheckbox) return;
-
- // Select/Deselect all
- selectAllCheckbox.addEventListener('change', function() {
+ const selectAllCheckbox = document.getElementById(selectAllId);
+
+ if (!selectAllCheckbox) return;
+
+ const headerCell = selectAllCheckbox.closest('th');
+ if (headerCell) {
+ if (!isInboxView) {
+ headerCell.style.textAlign = 'center';
+ headerCell.style.verticalAlign = 'middle';
+ headerCell.style.paddingLeft = '0';
+ headerCell.style.paddingRight = '0';
+ headerCell.style.display = 'flex';
+ headerCell.style.alignItems = 'center';
+ headerCell.style.justifyContent = 'center';
+ }
+ }
+
+ if (!isInboxView) {
+ selectAllCheckbox.style.margin = '0 auto';
+ selectAllCheckbox.style.display = 'block';
+ selectAllCheckbox.style.flex = '0 0 auto';
+ }
+
+ // Select/Deselect all
+ selectAllCheckbox.addEventListener('change', function() {
const checkboxes = tableElement.querySelectorAll(TABLE_SELECTOR_ROW_CHECKBOX);
- checkboxes.forEach(cb => {
- cb.checked = this.checked;
- });
- updateSelectionCount(tableElement);
- });
+ checkboxes.forEach(cb => {
+ cb.checked = this.checked;
+ });
+ updateSelectionCount(tableElement);
+ });
// Handle individual checkbox changes
$(tableElement).on('change', TABLE_SELECTOR_ROW_CHECKBOX, function() {
@@ -353,12 +395,53 @@ function setupCheckboxHandlers(tableElement, isInboxView = false) {
// Also listen for clicks on the checkbox itself (for when it's clicked directly)
$(tableElement).on('click', TABLE_SELECTOR_ROW_CHECKBOX, function(e) {
- // Small delay to allow the checkbox state to update
- setTimeout(() => {
- updateSelectionCount(tableElement);
- }, 10);
- });
-}
+ // Small delay to allow the checkbox state to update
+ setTimeout(() => {
+ updateSelectionCount(tableElement);
+ }, 10);
+ });
+
+ const syncSelectionState = () => {
+ const checkedBoxes = tableElement.querySelectorAll(`${TABLE_SELECTOR_ROW_CHECKBOX}:checked`);
+ const allCheckboxes = tableElement.querySelectorAll(TABLE_SELECTOR_ROW_CHECKBOX);
+
+ selectAllCheckbox.checked = allCheckboxes.length > 0 && checkedBoxes.length === allCheckboxes.length;
+ selectAllCheckbox.indeterminate = checkedBoxes.length > 0 && checkedBoxes.length < allCheckboxes.length;
+ };
+
+ const restoreSelections = () => {
+ const manager = window.bulkActionsManager;
+ const selectedUids = manager?.selectedUids instanceof Set ? manager.selectedUids : null;
+
+ if (!selectedUids || selectedUids.size === 0) {
+ return false;
+ }
+
+ let restoredCount = 0;
+ tableElement.querySelectorAll(TABLE_SELECTOR_ROW_CHECKBOX).forEach((checkbox) => {
+ const uid = checkbox.dataset.uid;
+ const shouldBeChecked = uid && selectedUids.has(uid);
+ checkbox.checked = shouldBeChecked;
+ if (shouldBeChecked) {
+ restoredCount++;
+ }
+ });
+
+ return restoredCount > 0;
+ };
+
+ if (restoreSelections()) {
+ updateSelectionCount(tableElement);
+ }
+
+ syncSelectionState();
+ $(tableElement).on('draw.dt', () => {
+ if (restoreSelections()) {
+ updateSelectionCount(tableElement);
+ }
+ syncSelectionState();
+ });
+}
function updateSelectionCount(tableElement) {
const checkedBoxes = tableElement.querySelectorAll(`${TABLE_SELECTOR_ROW_CHECKBOX}:checked`);
diff --git a/resources/views/layouts/sidebar.blade.php b/resources/views/layouts/sidebar.blade.php
index 1854c55..40682b9 100644
--- a/resources/views/layouts/sidebar.blade.php
+++ b/resources/views/layouts/sidebar.blade.php
@@ -13,6 +13,28 @@
+@push('styles')
+
+@endpush
+
@push('scripts')
-@endpush
\ No newline at end of file
+@endpush
diff --git a/resources/views/ui/list.blade.php b/resources/views/ui/list.blade.php
index 8700a20..8d9a99c 100644
--- a/resources/views/ui/list.blade.php
+++ b/resources/views/ui/list.blade.php
@@ -1,8 +1,9 @@
-@extends(app('app.layout'))
-
-@php
- $entity = ucwords(str_replace('_', ' ', $table_schema->schema['entity']));
- $defaultView = $table_schema->schema['default_view_mode'] ?? 'table';
+@extends(app('app.layout'))
+
+@php
+ $schemaEntity = $table_schema->schema['entity'] ?? $table_schema->schema['business_entity'] ?? '';
+ $entity = ucwords(str_replace('_', ' ', $schemaEntity));
+ $defaultView = $table_schema->schema['default_view_mode'] ?? 'table';
$tabs = [
[
@@ -32,19 +33,32 @@
{{-- Actions Bar with Refresh, Bulk Actions, Select Hint, and View Toggle --}}
{{-- Left side: Refresh and Select Hint/Bulk Actions --}}
-
- {{-- Refresh Button (Always visible) --}}
-
-
- {{-- Select Hint (Visible when nothing selected) --}}
-
-
- Select any row for bulk actions
+
+ {{-- Refresh Button (Always visible) --}}
+
+
+ {{-- Selection Count Badge (Visible when items selected) --}}
+
+ 0 Selected
+
+
+ {{-- Clear Selection Button (Visible when items selected) --}}
+
+
+ {{-- Select Hint (Visible when nothing selected) --}}
+
+
+ Select any row for bulk actions
{{-- Bulk Actions (Visible when items selected) --}}
@@ -207,25 +221,12 @@ class="btn btn-sm btn-outline-secondary text-muted border-0 dropdown-toggle"
- {{-- Right side: Selection Count, Clear Button, and View Toggle --}}
-
- {{-- Selection Count Badge (Visible when items selected) --}}
-
- 0 Selected
-
-
- {{-- Clear Selection Button (Visible when items selected) --}}
-
-
- {{-- View Toggle Button (Always visible) --}}
-
-@endsection
\ No newline at end of file
+@endsection
diff --git a/routes/api.php b/routes/api.php
index 3555d3b..3c421b2 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -5,6 +5,7 @@
use Iquesters\UserInterface\Http\Controllers\Api\Meta\FormController;
use Iquesters\UserInterface\Http\Controllers\Api\Meta\TableController;
use Iquesters\UserInterface\Http\Controllers\DynamicEntityController;
+use Iquesters\UserInterface\Http\Controllers\DynamicBusinessEntityController;
use Iquesters\UserInterface\Http\Controllers\UIController;
/*
@@ -40,7 +41,7 @@
// Public API (No Sanctum)
Route::get('noauth/form/{slug}', [FormController::class, 'getNoAuthFormSchema'])
->name('noauth.form');
-
+
// Protected APIs (Require Sanctum)
Route::middleware('auth:sanctum')->group(function () {
@@ -50,6 +51,12 @@
Route::get('entity/list/{entity_name}', [DynamicEntityController::class, 'list'])
->name('api.entity.list');
+ Route::get('business-entity/list/{business_entity_name}', [DynamicBusinessEntityController::class, 'list'])
+ ->name('api.business-entity.list');
+
+ Route::post('business-entity/store/{business_entity_name}', [DynamicBusinessEntityController::class, 'store'])
+ ->name('api.business-entity.store');
+
Route::post('entity/store/{entity_name}', [DynamicEntityController::class, 'store'])
->name('api.entity.store');
@@ -61,8 +68,18 @@
Route::get('entity/show/{entity_name}/{data_uid}', [DynamicEntityController::class, 'show'])
->name('api.entity.show');
+
+ Route::put('business-entity/update/{business_entity_name}/{data_uid}', [DynamicBusinessEntityController::class, 'update'])
+ ->name('api.business-entity.update');
+
+ Route::get(
+ 'business-entity/show/{business_entity_name}/{data_uid}', [DynamicBusinessEntityController::class, 'show'])
+ ->name('api.business-entity.show');
+
+ Route::delete('business-entity/delete/{business_entity_name}/{data_uid}', [DynamicBusinessEntityController::class, 'delete'])
+ ->name('api.business-entity.delete');
});
-});
+ });
Route::prefix('api')
diff --git a/src/Http/Controllers/DynamicBusinessEntityController.php b/src/Http/Controllers/DynamicBusinessEntityController.php
new file mode 100644
index 0000000..6e8b157
--- /dev/null
+++ b/src/Http/Controllers/DynamicBusinessEntityController.php
@@ -0,0 +1,1184 @@
+resolveBusinessEntity($business_entity_name);
+ $mapping = $this->parseFieldMapping($businessEntity);
+ $adjacency = $this->buildAdjacencyList($mapping['relationships']);
+ [$offset, $length, $page] = $this->parsePagination($request);
+
+ [$totalCount, $records] = $this->fetchPrimaryRecords(
+ $mapping['primary_entity'],
+ $mapping['entity_config_map'],
+ $adjacency,
+ $offset,
+ $length
+ );
+
+ if ($records->isNotEmpty()) {
+ $records = $this->attachMetaToRecords(
+ $mapping['primary_entity'],
+ $records,
+ $mapping['entity_config_map'][$mapping['primary_entity']]['meta_fields'] ?? []
+ );
+
+ $records = $this->attachRelationships(
+ $mapping['primary_entity'],
+ $records,
+ $mapping['entity_config_map'],
+ $adjacency,
+ [$mapping['primary_entity']]
+ );
+ }
+
+ $this->logInfo(sprintf(
+ 'DynamicBusinessEntityController@list | business_entity=%s | primary=%s | total=%d | offset=%d | length=%d',
+ $business_entity_name,
+ $mapping['primary_entity'],
+ $totalCount,
+ $offset,
+ $length
+ ));
+
+ return ApiResponse::success(
+ $records,
+ 'Request successful',
+ 200,
+ [
+ 'pagination' => [
+ 'total' => $totalCount,
+ 'per_page' => $length,
+ 'current_page' => $page,
+ 'last_page' => (int) ceil($totalCount / $length),
+ 'has_more' => ($page * $length) < $totalCount,
+ ]
+ ]
+ );
+ } catch (Throwable $e) {
+ $this->logError(sprintf(
+ 'DynamicBusinessEntityController@list error | business_entity=%s | message=%s | file=%s | line=%d',
+ $business_entity_name,
+ $e->getMessage(),
+ $e->getFile(),
+ $e->getLine()
+ ));
+
+ return ApiResponse::error('Something went wrong while processing business entity data.', 500);
+ }
+ }
+
+ public function store(Request $request, string $business_entity_name)
+ {
+ try {
+ return $this->renderBusinessEntityAction($request, $business_entity_name, 'created');
+ } catch (Throwable $e) {
+ $this->logError(sprintf(
+ 'DynamicBusinessEntityController@store error | business_entity=%s | message=%s',
+ $business_entity_name,
+ $e->getMessage()
+ ));
+
+ return ApiResponse::error('Something went wrong while processing business entity data.', 500);
+ }
+ }
+
+ public function show(
+ Request $request,
+ string $business_entity_name,
+ string $data_uid
+ ) {
+ try {
+
+ $businessEntity = $this->resolveBusinessEntity(
+ $business_entity_name
+ );
+
+ $mapping = $this->parseFieldMapping(
+ $businessEntity
+ );
+
+ $adjacency = $this->buildAdjacencyList(
+ $mapping['relationships']
+ );
+
+ $record = $this->fetchPrimaryRecord(
+ $mapping['primary_entity'],
+ $mapping['entity_config_map'],
+ $adjacency,
+ $data_uid
+ );
+
+ if (! $record) {
+ return ApiResponse::success(
+ [
+ 'business_entity' => $businessEntity->slug,
+ 'business_entity_name' => $businessEntity->business_entity_name,
+ 'data_uid' => $data_uid,
+ 'record' => null,
+ 'field_mapping' => $mapping,
+ ],
+ 'Request successful'
+ );
+ }
+
+ $records = collect([$record]);
+
+ $records = $this->attachMetaToRecords(
+ $mapping['primary_entity'],
+ $records,
+ $mapping['entity_config_map'][$mapping['primary_entity']]['meta_fields'] ?? []
+ );
+
+ $records = $this->attachRelationships(
+ $mapping['primary_entity'],
+ $records,
+ $mapping['entity_config_map'],
+ $adjacency,
+ [$mapping['primary_entity']]
+ );
+
+ $record = $this->aliasBusinessEntityRecordForForm($records->first(), $mapping);
+ $formData = $this->buildBusinessEntityFormData($record, $mapping);
+ $record = $this->applyBusinessEntityFormDataAliases($record, $formData);
+ $record->form_data = $formData;
+
+ return ApiResponse::success(
+ $record,
+ 'Request successful'
+ );
+ } catch (Throwable $e) {
+
+ $this->logError(sprintf(
+ 'DynamicBusinessEntityController@show error | business_entity=%s | data_uid=%s | message=%s',
+ $business_entity_name,
+ $data_uid,
+ $e->getMessage()
+ ));
+
+ return ApiResponse::error(
+ 'Something went wrong while processing business entity data.',
+ 500
+ );
+ }
+ }
+
+ public function update(
+ Request $request,
+ string $business_entity_name,
+ string $data_uid
+ ) {
+ try {
+ return $this->renderBusinessEntityAction($request, $business_entity_name, 'updated', $data_uid);
+ } catch (Throwable $e) {
+ $this->logError(sprintf(
+ 'DynamicBusinessEntityController@update error | business_entity=%s | data_uid=%s | message=%s',
+ $business_entity_name,
+ $data_uid,
+ $e->getMessage()
+ ));
+
+ return ApiResponse::error('Something went wrong while processing business entity data.', 500);
+ }
+ }
+
+ public function delete(
+ Request $request,
+ string $business_entity_name,
+ string $data_uid
+ ) {
+ try {
+ return $this->renderBusinessEntityAction($request, $business_entity_name, 'deleted', $data_uid);
+ } catch (Throwable $e) {
+ $this->logError(sprintf(
+ 'DynamicBusinessEntityController@delete error | business_entity=%s | data_uid=%s | message=%s',
+ $business_entity_name,
+ $data_uid,
+ $e->getMessage()
+ ));
+
+ return ApiResponse::error('Something went wrong while processing business entity data.', 500);
+ }
+ }
+
+ // =========================================================================
+ // Step 1 — Resolve the BusinessEntity record
+ // =========================================================================
+
+ protected function resolveBusinessEntity(string $businessEntityName): BusinessEntity
+ {
+ $record = BusinessEntity::where('slug', $businessEntityName)
+ ->orWhere('uid', $businessEntityName)
+ ->orWhere('business_entity_name', $businessEntityName)
+ ->first();
+
+ if (! $record) {
+ throw new \RuntimeException("Business entity '{$businessEntityName}' not found.");
+ }
+
+ return $record;
+ }
+
+ /**
+ * Business entity forms are generated with prefixed field ids such as
+ * "users_name" or "users_m_session_token". The show endpoint keeps the
+ * original record shape for compatibility, but we add these aliases so the
+ * form renderer can hydrate fields without special-casing business entities.
+ */
+ protected function aliasBusinessEntityRecordForForm(object $record, array $mapping): object
+ {
+ $aliasRecord = clone $record;
+ $primaryEntity = (string) ($mapping['primary_entity'] ?? '');
+
+ foreach ($mapping['entity_config_map'] ?? [] as $entityConfig) {
+ $sourceEntity = (string) ($entityConfig['entity'] ?? '');
+
+ foreach (($entityConfig['fields'] ?? []) as $field) {
+ if (! is_array($field)) {
+ continue;
+ }
+
+ $alias = $field['name'] ?? null;
+ $sourceField = $field['source_field'] ?? $field['field'] ?? null;
+
+ if (! $alias || ! $sourceField) {
+ continue;
+ }
+
+ if ($sourceEntity === $primaryEntity && isset($record->{$sourceField})) {
+ $aliasRecord->{$alias} = $record->{$sourceField};
+ continue;
+ }
+
+ $aliasRecord->{$alias} = $this->resolveBusinessEntityFieldValue($record, $sourceEntity, $sourceField);
+ }
+
+ foreach (($entityConfig['meta_fields'] ?? []) as $field) {
+ if (! is_array($field)) {
+ continue;
+ }
+
+ $alias = $field['meta_key'] ?? null;
+ $sourceMetaKey = $field['source_meta_key'] ?? null;
+
+ if (! $alias || ! $sourceMetaKey) {
+ continue;
+ }
+
+ $aliasRecord->{$alias} = $this->resolveBusinessEntityMetaValue($record, $sourceEntity, $sourceMetaKey);
+ }
+ }
+
+ return $aliasRecord;
+ }
+
+ protected function buildBusinessEntityFormData(object $record, array $mapping): array
+ {
+ $formData = [];
+ $primaryEntity = (string) ($mapping['primary_entity'] ?? '');
+
+ foreach ($mapping['entity_config_map'] ?? [] as $entityConfig) {
+ $sourceEntity = (string) ($entityConfig['entity'] ?? '');
+
+ foreach (($entityConfig['fields'] ?? []) as $field) {
+ if (! is_array($field)) {
+ continue;
+ }
+
+ $alias = $field['name'] ?? null;
+ $sourceField = $field['source_field'] ?? $field['field'] ?? null;
+
+ if (! $alias || ! $sourceField) {
+ continue;
+ }
+
+ $formData[$alias] = $this->resolveBusinessEntityFieldValue($record, $sourceEntity, $sourceField);
+
+ if ($sourceEntity === $primaryEntity && isset($record->{$sourceField})) {
+ $formData[$alias] = $record->{$sourceField};
+ }
+ }
+
+ foreach (($entityConfig['meta_fields'] ?? []) as $field) {
+ if (! is_array($field)) {
+ continue;
+ }
+
+ $alias = $field['meta_key'] ?? null;
+ $sourceMetaKey = $field['source_meta_key'] ?? null;
+
+ if (! $alias || ! $sourceMetaKey) {
+ continue;
+ }
+
+ $formData[$alias] = $this->resolveBusinessEntityMetaValue($record, $sourceEntity, $sourceMetaKey);
+ }
+ }
+
+ return $formData;
+ }
+
+ protected function applyBusinessEntityFormDataAliases(object $record, array $formData): object
+ {
+ $aliasedRecord = clone $record;
+
+ foreach ($formData as $key => $value) {
+ $aliasedRecord->{$key} = $value;
+ }
+
+ return $aliasedRecord;
+ }
+
+ protected function resolveBusinessEntityFieldValue(object $record, string $sourceEntity, string $sourceField): mixed
+ {
+ if (isset($record->{$sourceField})) {
+ return $record->{$sourceField};
+ }
+
+ $candidateKeys = array_values(array_filter(array_unique([
+ $sourceEntity,
+ Str::singular($sourceEntity),
+ Str::plural($sourceEntity),
+ ])));
+
+ foreach ($candidateKeys as $candidateKey) {
+ if (! isset($record->{$candidateKey})) {
+ continue;
+ }
+
+ $candidateValue = $record->{$candidateKey};
+
+ if (is_object($candidateValue) && isset($candidateValue->{$sourceField})) {
+ return $candidateValue->{$sourceField};
+ }
+
+ if ($candidateValue instanceof Collection) {
+ $firstItem = $candidateValue->first();
+
+ if (is_object($firstItem) && isset($firstItem->{$sourceField})) {
+ return $firstItem->{$sourceField};
+ }
+ }
+
+ if (is_array($candidateValue) && array_key_exists($sourceField, $candidateValue)) {
+ return $candidateValue[$sourceField];
+ }
+ }
+
+ if (isset($record->{$sourceField})) {
+ return $record->{$sourceField};
+ }
+
+ return null;
+ }
+
+ protected function resolveBusinessEntityMetaValue(object $record, string $sourceEntity, string $sourceMetaKey): mixed
+ {
+ $candidateKeys = array_values(array_filter(array_unique([
+ $sourceEntity,
+ Str::singular($sourceEntity),
+ Str::plural($sourceEntity),
+ ])));
+
+ foreach ($candidateKeys as $candidateKey) {
+ if (! isset($record->{$candidateKey}) || ! is_object($record->{$candidateKey})) {
+ continue;
+ }
+
+ $candidateValue = $record->{$candidateKey};
+
+ if (isset($candidateValue->meta) && is_iterable($candidateValue->meta)) {
+ foreach ($candidateValue->meta as $metaItem) {
+ if (($metaItem->meta_key ?? null) === $sourceMetaKey) {
+ return $metaItem->meta_value ?? null;
+ }
+ }
+ }
+ }
+
+ if (isset($record->meta) && is_iterable($record->meta)) {
+ foreach ($record->meta as $metaItem) {
+ if (($metaItem->meta_key ?? null) === $sourceMetaKey) {
+ return $metaItem->meta_value ?? null;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ protected function renderBusinessEntityAction(
+ Request $request,
+ string $businessEntityName,
+ string $verb,
+ ?string $dataUid = null
+ ) {
+ $businessEntity = $this->resolveBusinessEntity($businessEntityName);
+ $mapping = $this->parseFieldMapping($businessEntity);
+
+ return ApiResponse::success([
+ 'business_entity' => $businessEntity->slug,
+ 'business_entity_name' => $businessEntity->business_entity_name,
+ 'data_uid' => $dataUid,
+ 'action' => $verb,
+ 'field_mapping' => $mapping,
+ 'request' => $request->except(['_token', '_method']),
+ ], 'Request successful');
+ }
+
+ // =========================================================================
+ // Step 2 — Parse and validate field_mapping
+ // =========================================================================
+
+ protected function parseFieldMapping(BusinessEntity $businessEntity): array
+ {
+ $mapping = $businessEntity->field_mapping;
+
+ if (empty($mapping) || empty($mapping['entities'])) {
+ throw new \InvalidArgumentException('Business entity has no field mapping configured.');
+ }
+
+ $primaryEntity = $mapping['primary_entity'] ?? null;
+
+ if (! $primaryEntity) {
+ throw new \InvalidArgumentException('Business entity has no primary entity defined.');
+ }
+
+ $entityConfigMap = collect($mapping['entities'])
+ ->sortBy('sort_order')
+ ->mapWithKeys(function (array $entityConfig) {
+ $tableName = $this->resolveMappedEntityTableName($entityConfig['entity'] ?? null);
+
+ return [$tableName => $this->normalizeEntityConfig($entityConfig, $tableName)];
+ })
+ ->toArray();
+
+ $primaryEntity = $this->resolveMappedEntityTableName($primaryEntity);
+
+ $relationships = $mapping['relationships'] ?? [];
+ $relationships = $this->normalizeRelationships($relationships);
+
+ $this->validateMappingTables(
+ $entityConfigMap,
+ $relationships
+ );
+
+ return [
+ 'primary_entity' => $primaryEntity,
+ 'entity_config_map' => $entityConfigMap,
+ 'relationships' => $relationships,
+ ];
+ }
+
+ protected function validateMappingTables(array $entityConfigMap, array $relationships): void
+ {
+ foreach ($entityConfigMap as $tableName => $_) {
+ if (! Schema::hasTable($tableName)) {
+ throw new \RuntimeException("Table '{$tableName}' referenced in field mapping does not exist.");
+ }
+ }
+
+ foreach ($relationships as $rel) {
+ if (! empty($rel['through']) && ! Schema::hasTable($rel['through'])) {
+ throw new \RuntimeException("Pivot table '{$rel['through']}' referenced in relationship does not exist.");
+ }
+ }
+ }
+
+ protected function normalizeEntityConfig(array $entityConfig, string $tableName): array
+ {
+ $entityConfig['entity'] = $tableName;
+
+ if (! empty($entityConfig['meta_fields']) && is_array($entityConfig['meta_fields'])) {
+ $entityConfig['meta_fields'] = array_map(function ($field) use ($tableName) {
+ if (! is_array($field)) {
+ return $field;
+ }
+
+ $field['source_entity'] = $tableName;
+
+ return $field;
+ }, $entityConfig['meta_fields']);
+ }
+
+ if (! empty($entityConfig['fields']) && is_array($entityConfig['fields'])) {
+ $entityConfig['fields'] = array_map(function ($field) use ($tableName) {
+ if (! is_array($field)) {
+ return $field;
+ }
+
+ $field['source_entity'] = $tableName;
+
+ return $field;
+ }, $entityConfig['fields']);
+ }
+
+ return $entityConfig;
+ }
+
+ protected function normalizeRelationships(array $relationships): array
+ {
+ return array_map(function (array $relationship) {
+ if (isset($relationship['source_entity'])) {
+ $relationship['source_entity'] = $this->resolveMappedEntityTableName($relationship['source_entity']);
+ }
+
+ if (isset($relationship['target_entity'])) {
+ $relationship['target_entity'] = $this->resolveMappedEntityTableName($relationship['target_entity']);
+ }
+
+ if (isset($relationship['through'])) {
+ $relationship['through'] = $this->resolveMappedEntityTableName($relationship['through']);
+ }
+
+ return $relationship;
+ }, $relationships);
+ }
+
+ protected function resolveMappedEntityTableName(?string $mappedEntity): string
+ {
+ $mappedEntity = trim((string) $mappedEntity);
+
+ if ($mappedEntity === '') {
+ throw new \InvalidArgumentException('Mapped entity name is empty.');
+ }
+
+ if (Schema::hasTable($mappedEntity)) {
+ return $mappedEntity;
+ }
+
+ $modelTable = $this->resolveModelTableName($mappedEntity);
+
+ if ($modelTable !== null && Schema::hasTable($modelTable)) {
+ return $modelTable;
+ }
+
+ $entity = FoundationEntity::query()
+ ->where('slug', $mappedEntity)
+ ->orWhere('entity_name', $mappedEntity)
+ ->first();
+
+ if ($entity) {
+ $tableName = (string) ($entity->getMeta('published_table_name') ?: $entity->getMeta('table_name'));
+
+ if ($tableName !== '' && Schema::hasTable($tableName)) {
+ return $tableName;
+ }
+ }
+
+ $slugified = Str::lower(Str::of($mappedEntity)->slug('_')->toString());
+
+ if ($slugified !== '' && Schema::hasTable($slugified)) {
+ return $slugified;
+ }
+
+ $pluralized = Str::pluralStudly(Str::studly($slugified));
+ $pluralized = Str::snake($pluralized);
+
+ if ($pluralized !== '' && Schema::hasTable($pluralized)) {
+ return $pluralized;
+ }
+
+ throw new \RuntimeException("Table '{$mappedEntity}' referenced in field mapping does not exist.");
+ }
+
+ protected function resolveModelTableName(string $mappedEntity): ?string
+ {
+ $modelClass = $this->findModelClass($mappedEntity);
+
+ if (! $modelClass) {
+ return null;
+ }
+
+ /** @var \Illuminate\Database\Eloquent\Model $model */
+ $model = new $modelClass();
+
+ return $model->getTable();
+ }
+
+ // =========================================================================
+ // Step 3 — Build adjacency list from global relationships
+ // =========================================================================
+
+ /**
+ * Returns: [ source_entity => [ relationship, … ], … ]
+ */
+ protected function buildAdjacencyList(array $relationships): array
+ {
+ $adjacency = [];
+
+ foreach ($relationships as $rel) {
+ $adjacency[$rel['source_entity']][] = $rel;
+ }
+
+ return $adjacency;
+ }
+
+ // =========================================================================
+ // Step 4 — Fetch primary entity records via Eloquent
+ // =========================================================================
+
+ protected function fetchPrimaryRecords(
+ string $primaryEntity,
+ array $entityConfigMap,
+ array $adjacency,
+ int $offset,
+ int $length
+ ): array {
+ $config = $entityConfigMap[$primaryEntity];
+ $sourceKeys = $this->outgoingSourceKeys($primaryEntity, $adjacency);
+ $selectCols = $this->resolveSelectColumns($primaryEntity, $config['fields'] ?? [], $sourceKeys);
+
+ $model = $this->resolveModelInstance($primaryEntity);
+ $totalCount = $model->newQuery()->count();
+ $records = $model->newQuery()
+ ->select($selectCols)
+ ->offset($offset)
+ ->limit($length)
+ ->get();
+
+ return [$totalCount, $records];
+ }
+
+ protected function fetchPrimaryRecord(
+ string $primaryEntity,
+ array $entityConfigMap,
+ array $adjacency,
+ string $dataUid
+ ): ?object {
+ $config = $entityConfigMap[$primaryEntity];
+
+ $sourceKeys = $this->outgoingSourceKeys(
+ $primaryEntity,
+ $adjacency
+ );
+
+ $selectCols = $this->resolveSelectColumns(
+ $primaryEntity,
+ $config['fields'] ?? [],
+ $sourceKeys
+ );
+
+ $model = $this->resolveModelInstance(
+ $primaryEntity
+ );
+
+ $referenceColumn = $this->resolveReferenceColumn(
+ $primaryEntity,
+ $dataUid
+ );
+
+ return $model->newQuery()
+ ->select($selectCols)
+ ->where($referenceColumn, $dataUid)
+ ->first();
+ }
+
+ protected function resolveReferenceColumn(
+ string $entity,
+ string $dataUid
+ ): string {
+ $hasUid = Schema::hasColumn($entity, 'uid');
+ $hasId = Schema::hasColumn($entity, 'id');
+
+ if ($hasUid && is_numeric($dataUid)) {
+ throw new \InvalidArgumentException(
+ "This table uses 'uid' as the primary reference. Please pass a valid UID instead of an ID."
+ );
+ }
+
+ if ($hasUid) {
+ return 'uid';
+ }
+
+ if ($hasId) {
+ return 'id';
+ }
+
+ throw new \InvalidArgumentException(
+ "Table {$entity} has no 'uid' or 'id' column."
+ );
+ }
+
+ // =========================================================================
+ // Step 5 — Attach meta fields
+ // =========================================================================
+
+ protected function attachMetaToRecords(
+ string $entity,
+ Collection $records,
+ array $metaFieldDefinitions
+ ): Collection {
+ if (empty($metaFieldDefinitions)) {
+ return $records->map(function ($record) {
+ $record->meta = (object) [];
+ return $record;
+ });
+ }
+
+ $metaTable = $this->resolveMetaTable($entity);
+
+ if (! $metaTable) {
+ return $records->map(function ($record) {
+ $record->meta = (object) [];
+ return $record;
+ });
+ }
+
+ $parentIds = $records->pluck('id')->filter()->values();
+ $requestedKeys = array_column($metaFieldDefinitions, 'meta_key');
+
+ $metaRows = DB::table($metaTable)
+ ->whereIn('ref_parent', $parentIds)
+ ->whereIn('meta_key', $requestedKeys)
+ ->get(['ref_parent', 'meta_key', 'meta_value']);
+
+ $grouped = $metaRows->groupBy('ref_parent');
+
+ return $records->map(function ($record) use ($grouped) {
+ $flat = [];
+ foreach ($grouped[$record->id] ?? collect() as $item) {
+ $flat[$item->meta_key] = $item->meta_value;
+ }
+ $record->meta = (object) $flat;
+ return $record;
+ });
+ }
+
+ // =========================================================================
+ // Step 6 — Attach related entities (dispatcher)
+ // =========================================================================
+
+ protected function attachRelationships(
+ string $currentEntity,
+ Collection $records,
+ array $entityConfigMap,
+ array $adjacency,
+ array $visited
+ ): Collection {
+ if (! isset($adjacency[$currentEntity])) {
+ return $records;
+ }
+
+ foreach ($adjacency[$currentEntity] as $relationship) {
+ $targetEntity = $relationship['target_entity'];
+
+ if (in_array($targetEntity, $visited, true)) {
+ continue;
+ }
+
+ if (! isset($entityConfigMap[$targetEntity])) {
+ $this->logWarning("Relationship references '{$targetEntity}' which is not in field mapping — skipping.");
+ continue;
+ }
+
+ if (! Schema::hasTable($targetEntity)) {
+ $this->logWarning("Relationship references table '{$targetEntity}' which does not exist — skipping.");
+ continue;
+ }
+
+ // Route to the through/pivot handler when a pivot table is declared,
+ // regardless of the type label. Type only controls plurality (singular vs plural).
+ $records = ! empty($relationship['through'])
+ ? $this->attachThroughRelationship($records, $relationship, $entityConfigMap, $adjacency, $visited)
+ : $this->attachDirectRelationship($records, $relationship, $entityConfigMap, $adjacency, $visited);
+ }
+
+ return $records;
+ }
+
+ // =========================================================================
+ // Step 6a — Direct relationship (belongs_to / has_one / has_many)
+ // =========================================================================
+
+ protected function attachDirectRelationship(
+ Collection $records,
+ array $relationship,
+ array $entityConfigMap,
+ array $adjacency,
+ array $visited
+ ): Collection {
+ $targetEntity = $relationship['target_entity'];
+ $relType = $relationship['type'];
+ $sourceKey = $relationship['source_key'];
+ $targetKey = $relationship['target_key'];
+ $relationKey = $this->resolveRelationKey($targetEntity, $relType);
+ $newVisited = array_merge($visited, [$targetEntity]);
+
+ $targetConfig = $entityConfigMap[$targetEntity];
+ $outgoingKeys = $this->outgoingSourceKeys($targetEntity, $adjacency, $newVisited);
+ $selectCols = $this->resolveSelectColumns(
+ $targetEntity,
+ $targetConfig['fields'] ?? [],
+ array_merge([$targetKey], $outgoingKeys)
+ );
+
+ $sourceValues = $this->pluckSourceValues($records, $sourceKey);
+
+ if ($sourceValues->isEmpty()) {
+ return $this->attachEmpty($records, $relationKey, $relType);
+ }
+
+ $relatedRecords = $this->resolveModelInstance($targetEntity)
+ ->newQuery()
+ ->select($selectCols)
+ ->whereIn($targetKey, $sourceValues)
+ ->get();
+
+ $relatedRecords = $this->attachMetaToRecords($targetEntity, $relatedRecords, $targetConfig['meta_fields'] ?? []);
+ $relatedRecords = $this->attachRelationships($targetEntity, $relatedRecords, $entityConfigMap, $adjacency, $newVisited);
+
+ $grouped = $relatedRecords->groupBy(fn($r) => $r->{$targetKey} ?? null);
+ $isPlural = $this->isPlural($relType);
+
+ return $records->map(function ($record) use ($grouped, $sourceKey, $relationKey, $isPlural) {
+ $matches = $grouped[$record->{$sourceKey} ?? null] ?? collect();
+ $record->{$relationKey} = $isPlural ? $matches->values() : $matches->first();
+ return $record;
+ });
+ }
+
+ // =========================================================================
+ // Step 6b — Through/pivot relationship (has_many_through / has_one_through)
+ //
+ // Relationship shape:
+ // {
+ // "type": "has_many_through",
+ // "source_entity": "users",
+ // "target_entity": "roles",
+ // "through": "model_has_roles",
+ // "source_key": "id",
+ // "through_source_key": "model_id",
+ // "through_target_key": "role_id",
+ // "target_key": "id",
+ // "through_conditions": [["model_type", "=", "App\\Models\\User"]]
+ // }
+ // =========================================================================
+
+ protected function attachThroughRelationship(
+ Collection $records,
+ array $relationship,
+ array $entityConfigMap,
+ array $adjacency,
+ array $visited
+ ): Collection {
+ $targetEntity = $relationship['target_entity'];
+ $relType = $relationship['type'];
+ $throughTable = $relationship['through'];
+ $sourceKey = $relationship['source_key'];
+ $throughSourceKey = $relationship['through_source_key'];
+ $throughTargetKey = $relationship['through_target_key'];
+ $targetKey = $relationship['target_key'];
+ $throughConditions = $relationship['through_conditions'] ?? [];
+ $relationKey = $this->resolveRelationKey($targetEntity, $relType);
+ $newVisited = array_merge($visited, [$targetEntity]);
+
+ if (! Schema::hasTable($throughTable)) {
+ $this->logWarning("Pivot table '{$throughTable}' does not exist — skipping through-relation to '{$targetEntity}'.");
+ return $this->attachEmpty($records, $relationKey, $relType);
+ }
+
+ $sourceValues = $this->pluckSourceValues($records, $sourceKey);
+
+ if ($sourceValues->isEmpty()) {
+ return $this->attachEmpty($records, $relationKey, $relType);
+ }
+
+ // Fetch pivot rows
+ $pivotRows = $this->fetchPivotRows(
+ $throughTable,
+ $throughSourceKey,
+ $throughTargetKey,
+ $sourceValues,
+ $throughConditions
+ );
+
+ if ($pivotRows->isEmpty()) {
+ return $this->attachEmpty($records, $relationKey, $relType);
+ }
+
+ // Fetch target entity records
+ $targetConfig = $entityConfigMap[$targetEntity];
+ $outgoingKeys = $this->outgoingSourceKeys($targetEntity, $adjacency, $newVisited);
+ $selectCols = $this->resolveSelectColumns(
+ $targetEntity,
+ $targetConfig['fields'] ?? [],
+ array_merge([$targetKey], $outgoingKeys)
+ );
+
+ $targetIds = $pivotRows->pluck($throughTargetKey)->filter()->unique()->values();
+
+ $targetRecords = $this->resolveModelInstance($targetEntity)
+ ->newQuery()
+ ->select($selectCols)
+ ->whereIn($targetKey, $targetIds)
+ ->get();
+
+ $targetRecords = $this->attachMetaToRecords($targetEntity, $targetRecords, $targetConfig['meta_fields'] ?? []);
+ $targetRecords = $this->attachRelationships($targetEntity, $targetRecords, $entityConfigMap, $adjacency, $newVisited);
+
+ // Map pivot: source_value → [target_ids]
+ $pivotMap = $pivotRows
+ ->groupBy($throughSourceKey)
+ ->map(fn($rows) => $rows->pluck($throughTargetKey)->unique()->values());
+
+ $pivotRecordMap = $pivotRows
+ ->groupBy($throughSourceKey)
+ ->map(function ($rows) {
+ return $rows->map(function ($row) {
+ return (object) [
+ 'model_id' => $row->model_id ?? null,
+ 'model_type' => $row->model_type ?? null,
+ 'role_id' => $row->role_id ?? null,
+ 'id' => $row->id ?? null,
+ ];
+ })->values();
+ });
+
+ // Map target records: target_key_value → record
+ $targetMap = $targetRecords->keyBy(fn($r) => $r->{$targetKey});
+ $isPlural = $this->isPlural($relType);
+
+ return $records->map(function ($record) use ($pivotMap, $pivotRecordMap, $targetMap, $sourceKey, $relationKey, $throughTable, $isPlural) {
+ $matchedTargetIds = $pivotMap[$record->{$sourceKey} ?? null] ?? collect();
+ $record->{$throughTable} = $pivotRecordMap[$record->{$sourceKey} ?? null] ?? collect();
+
+ $matched = $matchedTargetIds
+ ->map(fn($id) => $targetMap[$id] ?? null)
+ ->filter()
+ ->values();
+
+ $record->{$relationKey} = $isPlural ? $matched : $matched->first();
+ return $record;
+ });
+ }
+
+ // =========================================================================
+ // Eloquent model resolution
+ // =========================================================================
+
+ /**
+ * Dynamically resolves an Eloquent model instance for a given table name.
+ *
+ * Resolution order:
+ * 1. Search $modelNamespaces for a class whose base name matches the
+ * studly-singular form of the table (e.g. "users" → "User").
+ * 2. If no registered model is found, return a generic anonymous Eloquent
+ * model bound to the table so queries still use the ORM pipeline.
+ */
+ protected function resolveModelInstance(string $tableName): Model
+ {
+ $modelClass = $this->findModelClass($tableName);
+
+ if ($modelClass) {
+ return new $modelClass;
+ }
+
+ return $this->makeDynamicModel($tableName);
+ }
+
+ protected function findModelClass(string $tableName): ?string
+ {
+ $modelName = Str::studly(Str::singular($tableName));
+
+ foreach ($this->modelNamespaces as $namespace) {
+ $class = $namespace . $modelName;
+ if (class_exists($class)) {
+ return $class;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Returns a generic Eloquent model bound to $tableName.
+ * Used as a fallback when no registered model class is found.
+ */
+ protected function makeDynamicModel(string $tableName): Model
+ {
+ return new class($tableName) extends Model {
+ public $timestamps = false;
+
+ public function __construct(string $table = '')
+ {
+ parent::__construct();
+ if ($table) {
+ $this->setTable($table);
+ }
+ }
+ };
+ }
+
+ // =========================================================================
+ // Pivot helper
+ // =========================================================================
+
+ protected function fetchPivotRows(
+ string $throughTable,
+ string $throughSourceKey,
+ string $throughTargetKey,
+ Collection $sourceValues,
+ array $throughConditions
+ ): Collection {
+ $query = DB::table($throughTable)
+ ->select([$throughSourceKey, $throughTargetKey])
+ ->whereIn($throughSourceKey, $sourceValues);
+
+ foreach ($throughConditions as [$col, $op, $val]) {
+ $query->where($col, $op, $val);
+ }
+
+ return $query->get();
+ }
+
+ // =========================================================================
+ // Shared helpers
+ // =========================================================================
+
+ protected function parsePagination(Request $request): array
+ {
+ $offset = max((int) $request->get('offset', 0), 0);
+ $length = max((int) $request->get('length', 50), 1);
+ $page = (int) floor($offset / $length) + 1;
+
+ return [$offset, $length, $page];
+ }
+
+ protected function pluckSourceValues(Collection $records, string $sourceKey): Collection
+ {
+ return $records
+ ->pluck($sourceKey)
+ ->filter(fn($v) => ! is_null($v) && $v !== '')
+ ->unique()
+ ->values();
+ }
+
+ protected function attachEmpty(Collection $records, string $relationKey, string $relType): Collection
+ {
+ $empty = $this->emptyRelationValue($relType);
+
+ return $records->map(function ($record) use ($relationKey, $empty) {
+ $record->{$relationKey} = $empty;
+ return $record;
+ });
+ }
+
+ protected function resolveSelectColumns(
+ string $entity,
+ array $fieldDefinitions,
+ array $additionalColumns = []
+ ): array {
+ $tableColumns = Schema::getColumnListing($entity);
+ $requestedCols = array_column($fieldDefinitions, 'field');
+ $selected = [];
+
+ if (in_array('id', $tableColumns, true)) {
+ $selected[] = 'id';
+ }
+
+ if (in_array('uid', $tableColumns, true)) {
+ $selected[] = 'uid';
+ }
+
+ foreach (array_merge($requestedCols, $additionalColumns) as $col) {
+ if ($col && in_array($col, $tableColumns, true) && ! in_array($col, $selected, true)) {
+ $selected[] = $col;
+ }
+ }
+
+ return $selected;
+ }
+
+ protected function outgoingSourceKeys(
+ string $entity,
+ array $adjacency,
+ array $visited = []
+ ): array {
+ if (! isset($adjacency[$entity])) {
+ return [];
+ }
+
+ $keys = [];
+ foreach ($adjacency[$entity] as $rel) {
+ if (! in_array($rel['target_entity'], $visited, true)) {
+ $keys[] = $rel['source_key'];
+ }
+ }
+
+ return array_unique($keys);
+ }
+
+ protected function resolveRelationKey(string $targetEntity, string $relType): string
+ {
+ return $this->isPlural($relType) ? $targetEntity : Str::singular($targetEntity);
+ }
+
+ /**
+ * Determines whether a relationship yields a collection.
+ * Through relationships use the same base types (has_many, belongs_to_many)
+ * — the pivot strategy is handled separately via the 'through' key.
+ */
+ protected function isPlural(string $relType): bool
+ {
+ return in_array($relType, ['has_many', 'belongs_to_many'], true);
+ }
+
+ protected function emptyRelationValue(string $relType): mixed
+ {
+ return $this->isPlural($relType) ? collect() : null;
+ }
+
+ protected function resolveMetaTable(string $entity): ?string
+ {
+ $candidates = [
+ "{$entity}_metas",
+ "{$entity}_meta",
+ Str::singular($entity) . '_meta',
+ Str::singular($entity) . '_metas',
+ ];
+
+ return collect($candidates)->first(fn($t) => Schema::hasTable($t));
+ }
+}