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) --}} - @@ -238,4 +239,4 @@ class="btn btn-sm btn-outline-secondary text-muted border-0"
-@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)); + } +}