From e1cd3f7bdd1cb1ecf77a743851ec044deb3fceef Mon Sep 17 00:00:00 2001 From: Debayan Date: Mon, 15 Jun 2026 19:21:59 +0530 Subject: [PATCH 1/6] feat: add dynamic business entity list and show APIs --- routes/api.php | 12 +- .../DynamicBusinessEntityController.php | 780 ++++++++++++++++++ 2 files changed, 790 insertions(+), 2 deletions(-) create mode 100644 src/Http/Controllers/DynamicBusinessEntityController.php diff --git a/routes/api.php b/routes/api.php index 3555d3b..1cad093 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,9 @@ 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('entity/store/{entity_name}', [DynamicEntityController::class, 'store']) ->name('api.entity.store'); @@ -61,8 +65,12 @@ Route::get('entity/show/{entity_name}/{data_uid}', [DynamicEntityController::class, 'show']) ->name('api.entity.show'); + + Route::get( + 'business-entity/show/{business_entity_name}/{data_uid}', [DynamicBusinessEntityController::class, 'show']) + ->name('api.business-entity.show'); }); -}); + }); Route::prefix('api') diff --git a/src/Http/Controllers/DynamicBusinessEntityController.php b/src/Http/Controllers/DynamicBusinessEntityController.php new file mode 100644 index 0000000..b017850 --- /dev/null +++ b/src/Http/Controllers/DynamicBusinessEntityController.php @@ -0,0 +1,780 @@ +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 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::error( + 'Record not found', + 404 + ); + } + + $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']] + ); + + return ApiResponse::success( + $records->first(), + '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 + ); + } + } + + // ========================================================================= + // 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; + } + + // ========================================================================= + // 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') + ->keyBy('entity') + ->toArray(); + + $relationships = $mapping['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."); + } + } + } + + // ========================================================================= + // 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()); + + // 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, $targetMap, $sourceKey, $relationKey, $isPlural) { + $matchedTargetIds = $pivotMap[$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)); + } +} From 2724e79a1f75f83e0e94ab6ecfc5efa6d37f6cfc Mon Sep 17 00:00:00 2001 From: Debayan Date: Thu, 18 Jun 2026 20:25:02 +0530 Subject: [PATCH 2/6] Add business entity list API support for table schema generation --- public/js/table/table-constants.js | 1 + public/js/table/table-init.js | 8 ++++---- public/js/table/table-providers.js | 13 +++++++++++-- public/js/table/table-runtime.js | 10 +++++----- resources/views/ui/list.blade.php | 13 +++++++------ 5 files changed, 28 insertions(+), 17 deletions(-) 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-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-runtime.js b/public/js/table/table-runtime.js index 4a76362..bc81211 100644 --- a/public/js/table/table-runtime.js +++ b/public/js/table/table-runtime.js @@ -217,7 +217,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 +256,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`); } }) diff --git a/resources/views/ui/list.blade.php b/resources/views/ui/list.blade.php index 8700a20..80a56db 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 = [ [ @@ -238,4 +239,4 @@ class="btn btn-sm btn-outline-secondary text-muted border-0"
-@endsection \ No newline at end of file +@endsection From 309fbfa8103eb1960a165753fe0ac514e01a6f76 Mon Sep 17 00:00:00 2001 From: Debayan Date: Tue, 23 Jun 2026 22:43:36 +0530 Subject: [PATCH 3/6] Add dynamic business entity support and table integration --- public/js/form/form-block-builder.js | 251 +++++++++-- public/js/form/form.js | 25 +- public/js/table/table-inbox-view.js | 130 +++--- public/js/table/table-renderers.js | 239 +++++++--- public/js/table/table-runtime.js | 151 +++++-- resources/views/layouts/sidebar.blade.php | 93 +++- resources/views/ui/list.blade.php | 64 +-- routes/api.php | 9 + .../DynamicBusinessEntityController.php | 416 +++++++++++++++++- 9 files changed, 1155 insertions(+), 223 deletions(-) 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-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-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 bc81211..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 @@ -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 80a56db..8d9a99c 100644 --- a/resources/views/ui/list.blade.php +++ b/resources/views/ui/list.blade.php @@ -33,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) --}} @@ -208,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) --}} - diff --git a/routes/api.php b/routes/api.php index 1cad093..3c421b2 100644 --- a/routes/api.php +++ b/routes/api.php @@ -54,6 +54,9 @@ 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'); @@ -65,10 +68,16 @@ 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'); }); }); diff --git a/src/Http/Controllers/DynamicBusinessEntityController.php b/src/Http/Controllers/DynamicBusinessEntityController.php index b017850..6e8b157 100644 --- a/src/Http/Controllers/DynamicBusinessEntityController.php +++ b/src/Http/Controllers/DynamicBusinessEntityController.php @@ -9,6 +9,7 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; +use Iquesters\Foundation\Models\Entity as FoundationEntity; use Iquesters\Foundation\Models\BusinessEntity; use Iquesters\Foundation\System\Http\ApiResponse; use Iquesters\Foundation\System\Traits\Loggable; @@ -108,6 +109,21 @@ public function list(Request $request, string $business_entity_name) } } + 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, @@ -135,9 +151,15 @@ public function show( ); if (! $record) { - return ApiResponse::error( - 'Record not found', - 404 + 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' ); } @@ -157,8 +179,13 @@ public function show( [$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( - $records->first(), + $record, 'Request successful' ); } catch (Throwable $e) { @@ -177,6 +204,44 @@ public function show( } } + 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 // ========================================================================= @@ -195,6 +260,213 @@ protected function resolveBusinessEntity(string $businessEntityName): BusinessEn 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 // ========================================================================= @@ -215,10 +487,17 @@ protected function parseFieldMapping(BusinessEntity $businessEntity): array $entityConfigMap = collect($mapping['entities']) ->sortBy('sort_order') - ->keyBy('entity') + ->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, @@ -247,6 +526,117 @@ protected function validateMappingTables(array $entityConfigMap, array $relation } } + 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 // ========================================================================= @@ -573,12 +963,26 @@ protected function attachThroughRelationship( ->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, $targetMap, $sourceKey, $relationKey, $isPlural) { + 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) From b517838d06f109c17e53c368e8bc46de2868bfe9 Mon Sep 17 00:00:00 2001 From: Debayan Date: Fri, 17 Jul 2026 15:35:49 +0530 Subject: [PATCH 4/6] IQU-enhancement-49: Move module navigation rendering to centralized sidebar logic --- public/js/navigation/navigation-renderer.js | 201 ++++++++++ .../views/components/module-tabs.blade.php | 342 ++++++++++-------- .../views/layouts/common/minibar.blade.php | 3 +- .../layouts/common/mobile-navbar.blade.php | 3 +- resources/views/layouts/header.blade.php | 1 - .../views/layouts/header/module-tab.blade.php | 4 +- resources/views/layouts/sidebar.blade.php | 177 --------- src/Http/Controllers/UIController.php | 45 ++- src/UserInterfaceServiceProvider.php | 16 +- 9 files changed, 456 insertions(+), 336 deletions(-) create mode 100644 public/js/navigation/navigation-renderer.js diff --git a/public/js/navigation/navigation-renderer.js b/public/js/navigation/navigation-renderer.js new file mode 100644 index 0000000..6a70aeb --- /dev/null +++ b/public/js/navigation/navigation-renderer.js @@ -0,0 +1,201 @@ +(function () { + function normalizeUrl(url) { + if (!url) return ''; + + try { + const parsed = new URL(url, window.location.origin); + return `${parsed.pathname}${parsed.search}${parsed.hash}`; + } catch (e) { + return url; + } + } + + function menuMatchesCurrentUrl(menu) { + const currentUrl = normalizeUrl(window.location.href); + const currentPath = normalizeUrl(window.location.pathname + window.location.search + window.location.hash); + + return (menu || []).some(item => { + if (!item || !item.url || item.url === '#') { + return false; + } + + const itemUrl = normalizeUrl(item.url); + return itemUrl === currentUrl || itemUrl === currentPath; + }); + } + + function getTabs(mode) { + return document.querySelectorAll(`.module-tab[data-view-mode="${mode}"]`); + } + + function getDropdownItems(mode) { + return document.querySelectorAll(`.dropdown-item[data-menu][data-view-mode="${mode}"]`); + } + + function findActiveElementByCurrentUrl(tabs, dropdownItems) { + const tabMatch = [...tabs].find(tab => { + try { + const menu = JSON.parse(tab.dataset.menu || '[]'); + return menuMatchesCurrentUrl(menu); + } catch (e) { + return false; + } + }); + + if (tabMatch) { + return tabMatch; + } + + return [...dropdownItems].find(item => { + try { + const menu = JSON.parse(item.dataset.menu || '[]'); + return menuMatchesCurrentUrl(menu); + } catch (e) { + return false; + } + }) || null; + } + + function renderSidebar(menu) { + const sidebar = document.getElementById('sidebarMenu'); + + if (!sidebar) { + return; + } + + sidebar.innerHTML = ''; + + if (!menu || menu.length === 0) { + sidebar.innerHTML = '
No menu available.
'; + return; + } + + const currentUrl = normalizeUrl(window.location.href); + const currentPath = normalizeUrl(window.location.pathname + window.location.search + window.location.hash); + + menu.forEach(item => { + const link = document.createElement('a'); + link.className = 'list-group-item dropdown-item ps-3 pe-2 py-1 d-flex align-items-center'; + link.href = item.url || '#'; + link.innerHTML = `${item.label || ''}`; + + const itemUrl = normalizeUrl(item.url); + if (itemUrl && (itemUrl === currentUrl || itemUrl === currentPath)) { + link.classList.add('active-module'); + link.setAttribute('aria-current', 'page'); + } + + sidebar.appendChild(link); + }); + } + + function setActive(element) { + const mode = element.dataset.viewMode; + const moduleUid = element.dataset.moduleUid || ''; + + getTabs(mode).forEach(t => t.classList.remove('active-module')); + getDropdownItems(mode).forEach(d => d.classList.remove('active')); + + element.classList.add(element.classList.contains('module-tab') ? 'active-module' : 'active'); + localStorage.setItem('activeModuleUid', moduleUid); + localStorage.setItem('activeModuleIndex', element.dataset.index); + } + + function navigateToModule(element) { + const href = element.dataset.href || element.getAttribute('href'); + + if (!href || href === '#' || href.startsWith('javascript:')) { + return false; + } + + localStorage.setItem('activeModuleUid', element.dataset.moduleUid || ''); + localStorage.setItem('activeModuleIndex', element.dataset.index || ''); + window.location.href = href; + return true; + } + + function activateCurrentModule() { + const mode = window.innerWidth < 992 ? 'mobile' : 'vertical'; + let tabs = getTabs(mode); + let dropdownItems = getDropdownItems(mode); + + if (!tabs.length) { + tabs = getTabs('desktop'); + } + if (!dropdownItems.length) { + dropdownItems = getDropdownItems('desktop'); + } + + if (!tabs.length) { + return; + } + + const urlModuleUid = new URL(window.location.href).searchParams.get('module_uid'); + const savedUid = localStorage.getItem('activeModuleUid'); + const savedIndex = localStorage.getItem('activeModuleIndex'); + let activeElement = null; + + if (urlModuleUid) { + activeElement = + [...tabs].find(t => t.dataset.moduleUid === urlModuleUid) || + [...dropdownItems].find(d => d.dataset.moduleUid === urlModuleUid) || + null; + } + + if (savedUid) { + activeElement = + activeElement || + [...tabs].find(t => t.dataset.moduleUid === savedUid) || + [...dropdownItems].find(d => d.dataset.moduleUid === savedUid) || + null; + } + + if (savedIndex) { + activeElement = + activeElement || + [...tabs].find(t => t.dataset.index === savedIndex) || + [...dropdownItems].find(d => d.dataset.index === savedIndex) || + null; + } + + if (!activeElement) { + activeElement = findActiveElementByCurrentUrl(tabs, dropdownItems); + } + + if (!activeElement) { + activeElement = tabs[0]; + } + + try { + const activeMenu = JSON.parse(activeElement.dataset.menu || '[]'); + renderSidebar(activeMenu); + } catch (e) { + renderSidebar([]); + } + + setActive(activeElement); + } + + document.addEventListener('click', function (e) { + const moduleTab = e.target.closest('.module-tab[data-href]'); + if (moduleTab) { + const href = moduleTab.dataset.href || moduleTab.getAttribute('href'); + if (href && href !== '#' && !href.startsWith('javascript:')) { + e.preventDefault(); + navigateToModule(moduleTab); + } + return; + } + + const dropdownItem = e.target.closest('.dropdown-item[data-menu][data-href]'); + if (dropdownItem) { + const href = dropdownItem.dataset.href || dropdownItem.getAttribute('href'); + if (href && href !== '#' && !href.startsWith('javascript:')) { + e.preventDefault(); + navigateToModule(dropdownItem); + } + } + }, true); + + document.addEventListener('DOMContentLoaded', activateCurrentModule); +})(); diff --git a/resources/views/components/module-tabs.blade.php b/resources/views/components/module-tabs.blade.php index 740775c..7bc1ccb 100644 --- a/resources/views/components/module-tabs.blade.php +++ b/resources/views/components/module-tabs.blade.php @@ -1,10 +1,66 @@ @php use Iquesters\Foundation\Support\ConfProvider; use Iquesters\Foundation\Enums\Module; + use Iquesters\Foundation\Models\Module as FoundationModule; + use Iquesters\Foundation\Models\Navigation; + use Illuminate\Support\Facades\Auth; + use Illuminate\Support\Facades\Route; // Configuration $viewMode = $viewMode ?? 'desktop'; $navStyle = ConfProvider::from(Module::USER_INFE)->large_screen_nav_style ?? null; + $navigationName = $navigationName ?? 'module_navigation'; + $foundationNavigationName = $foundationNavigationName ?? 'foundation_navigation'; + + $navigation = Navigation::where('name', $navigationName)->first(); + $navigationItems = $navigation + ? json_decode($navigation->getMeta('navigation_items') ?? '[]', true) + : []; + + $navigationItems = collect(is_array($navigationItems) ? $navigationItems : []) + ->filter(fn ($item) => ($item['enabled'] ?? true) && ($item['visible'] ?? true)) + ->values(); + + if (!Auth::check()) { + $navigationItems = collect(); + } + + $installedModules = collect($installedModules ?? []); + + if (Auth::check() && $installedModules->isNotEmpty()) { + $allowedModuleIds = $installedModules->pluck('id')->flip(); + + $navigationItems = $navigationItems->filter(function ($item) use ($allowedModuleIds) { + $moduleUid = $item['module_uid'] ?? $item['target_module_uid'] ?? null; + + if (!$moduleUid) { + return false; + } + + $module = FoundationModule::where('uid', $moduleUid)->first(); + + return $module ? $allowedModuleIds->has($module->id) : false; + })->values(); + } elseif (Auth::check()) { + $currentUser = Auth::user(); + $accessibleModuleIds = FoundationModule::getForUser($currentUser) + ->pluck('id') + ->flip(); + + $navigationItems = $navigationItems->filter(function ($item) use ($accessibleModuleIds) { + $moduleUid = $item['module_uid'] ?? $item['target_module_uid'] ?? null; + + if (!$moduleUid) { + return false; + } + + $module = FoundationModule::where('uid', $moduleUid)->first(); + + return $module ? $accessibleModuleIds->has($module->id) : false; + })->values(); + } else { + $navigationItems = collect(); + } if ($viewMode === 'mobile') { $maxVisible = ConfProvider::from(Module::USER_INFE)->small_screen_bottom_tabs; @@ -12,7 +68,7 @@ // Desktop / Vertical if ($navStyle === 'minibar') { // Show ALL modules, no dropdown - $maxVisible = $installedModules->count(); + $maxVisible = $navigationItems->count(); } else { $maxVisible = ConfProvider::from(Module::USER_INFE)->large_screen_module_tabs; } @@ -22,15 +78,16 @@ $featuredTabName = ConfProvider::from(Module::USER_INFE)->small_screen_featured_tab; $featuredPosition = ConfProvider::from(Module::USER_INFE)->small_screen_featured_position ?? 'center'; - // Process modules - $visibleModules = $installedModules->take($maxVisible); - $dropdownModules = $installedModules->skip($maxVisible); + // Process modules from navigation JSON + $visibleModules = $navigationItems->take($maxVisible); + $dropdownModules = $navigationItems->skip($maxVisible); // Reorder for featured tab if mobile $featuredIndex = -1; if ($viewMode === 'mobile' && $featuredTabName) { foreach ($visibleModules as $idx => $mod) { - if (strtolower(trim($mod->name)) === strtolower(trim($featuredTabName))) { + $moduleName = $mod['module_name'] ?? $mod['label'] ?? ''; + if (strtolower(trim($moduleName)) === strtolower(trim($featuredTabName))) { $featuredIndex = $idx; break; } @@ -47,102 +104,118 @@ } } - // Generate menu helper - use Iquesters\Foundation\Models\Navigation; - use Illuminate\Support\Facades\Route; + $normalizeUrl = function (?string $url): string { + if (!$url) { + return ''; + } + + $parsed = parse_url($url); + + return ($parsed['path'] ?? $url) . (isset($parsed['query']) ? '?' . $parsed['query'] : ''); + }; + + $withModuleQuery = function (string $url, ?string $moduleUid): string { + if (!$url || $url === '#' || !$moduleUid) { + return $url; + } + + $separator = str_contains($url, '?') ? '&' : '?'; + + return $url . $separator . 'module_uid=' . urlencode($moduleUid); + }; - $generateMenu = function ($module) { + $resolveModule = function ($value) { + if ($value === null || $value === '') { + return null; + } + + if (is_numeric($value)) { + return FoundationModule::where('id', $value)->first(); + } + + return FoundationModule::where('uid', $value)->first(); + }; + + $resolveNavigationUrl = function (array $item, ?FoundationModule $module = null): string { + $params = $item['params'] ?? []; - /** - * STEP 1: Load raw submenu from module meta - */ - $submenuItems = json_decode( - $module->getMeta('module_sidebar_menu') ?? '[]', - true - ); + if (!empty($item['default-table-schema-uid'])) { + $params['default_table_schema_uid'] = $item['default-table-schema-uid']; + } - if (!is_array($submenuItems) || empty($submenuItems)) { - return collect(); + $url = $item['url'] ?? null; + if (is_string($url) && $url !== '' && $url !== '#') { + return $url; } - /** - * STEP 2: Normalize submenu items (unique key) - */ - $submenu = collect($submenuItems) - ->map(function ($item) { - return array_merge($item, [ - '_key' => $item['route'] - . '::' - . ($item['default-table-schema-uid'] ?? 'none'), - ]); - }); - - /** - * STEP 3: Load saved order - */ - $navigation = Navigation::where( - 'name', - $module->name . '_sub_menu' - )->first(); - - $savedOrder = []; - - if ($navigation) { - $savedOrder = json_decode( - $navigation->getMeta('navigation_order') ?? '[]', - true - ); + $route = $item['route'] ?? null; + if ($route && Route::has($route)) { + return route($route, $params); } - $savedOrder = is_array($savedOrder) ? $savedOrder : []; - - /** - * STEP 4: Clean invalid keys - */ - $existingKeys = $submenu->pluck('_key')->toArray(); - $savedOrder = array_values(array_intersect($savedOrder, $existingKeys)); - - /** - * STEP 5: Append new submenu items - */ - $newKeys = array_diff($existingKeys, $savedOrder); - $finalKeys = array_merge($savedOrder, $newKeys); - - /** - * STEP 6: Build sidebar menu (CORRECT) - */ - return collect($finalKeys) - ->map(function ($key) use ($submenu) { - - $item = $submenu->firstWhere('_key', $key); - if (!$item) { - return null; + if ($module) { + $rawSidebarMenu = $module->getMeta('module_sidebar_menu') ?? '[]'; + $rawSidebarMenu = is_string($rawSidebarMenu) ? json_decode($rawSidebarMenu, true) : $rawSidebarMenu; + $rawSidebarMenu = is_array($rawSidebarMenu) ? $rawSidebarMenu : []; + + foreach ($rawSidebarMenu as $sidebarItem) { + if (!is_array($sidebarItem)) { + continue; } - $params = $item['params'] ?? []; + if (!($sidebarItem['enabled'] ?? true) || !($sidebarItem['visible'] ?? true)) { + continue; + } + + $sidebarParams = $sidebarItem['params'] ?? []; - if (!empty($item['default-table-schema-uid'])) { - $params['default_table_schema_uid'] - = $item['default-table-schema-uid']; + if (!empty($sidebarItem['default-table-schema-uid'])) { + $sidebarParams['default_table_schema_uid'] = $sidebarItem['default-table-schema-uid']; } - foreach ($params as $k => $v) { - if ($v === null) { - $params[$k] = request()->route($k); - } + $sidebarRoute = $sidebarItem['route'] ?? null; + if ($sidebarRoute && Route::has($sidebarRoute)) { + return route($sidebarRoute, $sidebarParams); } + } + } - return [ - 'icon' => $item['icon'] ?? null, - 'label' => $item['label'] ?? null, - 'url' => Route::has($item['route']) - ? route($item['route'], $params) - : '#', - ]; - }) - ->filter() - ->values(); + return '#'; + }; + + $buildSidebarMenuItem = function (array $item): array { + $params = $item['params'] ?? []; + + if (!empty($item['default-table-schema-uid'])) { + $params['default_table_schema_uid'] = $item['default-table-schema-uid']; + } + $route = $item['route'] ?? null; + + return [ + 'icon' => $item['icon'] ?? null, + 'label' => $item['label'] ?? null, + 'url' => $route && Route::has($route) + ? route($route, $params) + : '#', + ]; + }; + + $getModuleSidebarItems = function (array $module) use ($resolveModule): array { + $targetModule = $resolveModule($module['target_module_uid'] ?? $module['module_uid'] ?? null); + + if (!$targetModule) { + return []; + } + + $rawSidebarMenu = $targetModule->getMeta('module_sidebar_menu') ?? '[]'; + $rawSidebarMenu = is_string($rawSidebarMenu) ? json_decode($rawSidebarMenu, true) : $rawSidebarMenu; + $rawSidebarMenu = is_array($rawSidebarMenu) ? $rawSidebarMenu : []; + + return collect($rawSidebarMenu) + ->filter(fn ($item) => is_array($item) && (($item['enabled'] ?? true) && ($item['visible'] ?? true))) + ->values() + ->all(); }; // View mode configurations @@ -187,26 +260,38 @@ @foreach($visibleModules as $index => $module) @php - $menu = $generateMenu($module); + $submenuItems = $getModuleSidebarItems($module); + $moduleModel = $resolveModule($module['target_module_uid'] ?? $module['module_uid'] ?? null); $isFeatured = ($viewMode === 'mobile' && $featuredTabName && $index === $featuredIndex); + $moduleLabel = $module['label'] ?? ucfirst($module['module_name'] ?? $module['name'] ?? 'Module'); + $moduleIcon = $module['icon'] ?? 'fas fa-cubes'; + $moduleUid = $module['module_uid'] ?? $module['target_module_uid'] ?? ''; + $primaryUrl = $withModuleQuery($resolveNavigationUrl($module, $moduleModel), $moduleUid); + $menu = collect($submenuItems) + ->map(fn ($item) => is_array($item) ? $buildSidebarMenuItem($item) : []) + ->filter(fn ($item) => !empty($item['url']) && $item['url'] !== '#') + ->values(); @endphp - + title="{{ $moduleLabel }}">
- +
- {{ ucfirst($module->name) }} + {{ $moduleLabel }}
@endforeach @@ -227,18 +312,32 @@ class="module-tab module-tab-{{ $viewMode }} {{ $isFeatured ? 'module-tab-featur
@endif -@push('scripts') - -@endpush diff --git a/resources/views/layouts/common/minibar.blade.php b/resources/views/layouts/common/minibar.blade.php index ed2d02b..0b596c1 100644 --- a/resources/views/layouts/common/minibar.blade.php +++ b/resources/views/layouts/common/minibar.blade.php @@ -19,10 +19,9 @@
    @include('userinterface::components.module-tabs', [ - 'installedModules' => $installedModules, 'viewMode' => 'vertical' ])
- \ No newline at end of file + diff --git a/resources/views/layouts/common/mobile-navbar.blade.php b/resources/views/layouts/common/mobile-navbar.blade.php index 43a5de0..59992a0 100644 --- a/resources/views/layouts/common/mobile-navbar.blade.php +++ b/resources/views/layouts/common/mobile-navbar.blade.php @@ -1,4 +1,3 @@ @include('userinterface::components.module-tabs', [ - 'installedModules' => $installedModules, 'viewMode' => 'mobile' -]) \ No newline at end of file +]) diff --git a/resources/views/layouts/header.blade.php b/resources/views/layouts/header.blade.php index 6b87a72..6caa400 100644 --- a/resources/views/layouts/header.blade.php +++ b/resources/views/layouts/header.blade.php @@ -75,7 +75,6 @@
@include('userinterface::components.module-tabs', [ - 'installedModules' => $installedModules, 'viewMode' => 'desktop' ])
diff --git a/resources/views/layouts/header/module-tab.blade.php b/resources/views/layouts/header/module-tab.blade.php index 504723a..50d2db8 100644 --- a/resources/views/layouts/header/module-tab.blade.php +++ b/resources/views/layouts/header/module-tab.blade.php @@ -1,6 +1,6 @@
- @include('userinterface::components.module-tabs', ['installedModules' => $installedModules, 'orientation' => 'horizontal']) + @include('userinterface::components.module-tabs', ['viewMode' => 'desktop'])
-
\ No newline at end of file +
diff --git a/resources/views/layouts/sidebar.blade.php b/resources/views/layouts/sidebar.blade.php index 40682b9..cec60ed 100644 --- a/resources/views/layouts/sidebar.blade.php +++ b/resources/views/layouts/sidebar.blade.php @@ -34,180 +34,3 @@ } @endpush - -@push('scripts') - -@endpush diff --git a/src/Http/Controllers/UIController.php b/src/Http/Controllers/UIController.php index 075820c..bb3c9a5 100644 --- a/src/Http/Controllers/UIController.php +++ b/src/Http/Controllers/UIController.php @@ -5,6 +5,7 @@ use Illuminate\Http\Request; use Illuminate\Routing\Controller; use Illuminate\Support\Facades\Log; +use Iquesters\Foundation\Models\Module; use Iquesters\Foundation\Models\MasterData; use Iquesters\Foundation\System\Traits\Loggable; use Iquesters\UserInterface\Models\TableSchema; @@ -18,14 +19,30 @@ class UIController extends Controller * * @return FormSchema[] */ - public function list($table_schema_id) + public function list(Request $request, $table_schema_id) { try { $this->logMethodStart("table_schema_id={$table_schema_id}"); Log::info('In UIController list method', [ 'table_schema_id' => $table_schema_id, + 'module_uid' => $request->query('module_uid'), ]); + $moduleUid = $request->query('module_uid'); + if ($moduleUid) { + $module = Module::where('uid', $moduleUid)->first(); + if ($module) { + $targetSchemaUid = $this->resolveModuleDefaultTableSchemaUid($module); + + if ($targetSchemaUid && $targetSchemaUid !== $table_schema_id) { + return redirect()->route('ui.list', [ + 'default_table_schema_uid' => $targetSchemaUid, + 'module_uid' => $moduleUid, + ]); + } + } + } + $table_schema = TableSchema::where('uid', $table_schema_id)->first(); $this->logMethodEnd("table_schema_id={$table_schema_id}"); @@ -40,6 +57,32 @@ public function list($table_schema_id) } } + protected function resolveModuleDefaultTableSchemaUid(Module $module): ?string + { + $sidebarMenu = $module->getMeta('module_sidebar_menu'); + if (!$sidebarMenu) { + return null; + } + + $items = is_string($sidebarMenu) ? json_decode($sidebarMenu, true) : $sidebarMenu; + if (!is_array($items)) { + return null; + } + + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + + $schemaUid = $item['default-table-schema-uid'] ?? null; + if (is_string($schemaUid) && $schemaUid !== '') { + return $schemaUid; + } + } + + return null; + } + public function view(Request $request, $form_schema_id, $entity_uid = null) { try { diff --git a/src/UserInterfaceServiceProvider.php b/src/UserInterfaceServiceProvider.php index ca0bad4..ad8dd46 100644 --- a/src/UserInterfaceServiceProvider.php +++ b/src/UserInterfaceServiceProvider.php @@ -76,7 +76,7 @@ public function boot() \Iquesters\UserInterface\Support\SetupLinkInjector::inject(); }); - // Share installed modules globally + // Share role-filtered installed modules for navigation/tab rendering. $this->shareInstalledModules(); $this->publishes([ @@ -283,24 +283,30 @@ protected function shareInstalledModules(): void $moduleIds = $modules->pluck('id')->toArray(); /** - * STEP 2: Fetch primary navigation order + * STEP 2: Fetch module navigation JSON */ $navigation = $navigationModel::where( 'name', - 'primary_navigation' + 'module_navigation' )->first(); $savedOrder = []; if ($navigation) { $savedOrder = json_decode( - $navigation->getMeta('navigation_order') ?? '[]', + $navigation->getMeta('navigation_items') ?? '[]', true ); } $savedOrder = is_array($savedOrder) ? $savedOrder : []; + $savedOrder = collect($savedOrder) + ->pluck('module_uid') + ->filter() + ->values() + ->toArray(); + /** * STEP 3: Clean invalid / old module IDs */ @@ -539,4 +545,4 @@ public static function getCssAssets(): array return array_merge($themeCss, $otherCss); } -} \ No newline at end of file +} From be03cbb409023308d990df6e8a7e858d543bc43c Mon Sep 17 00:00:00 2001 From: Debayan Date: Fri, 24 Jul 2026 11:05:21 +0530 Subject: [PATCH 5/6] tabs rendering support for navigation, separate CSS classes for minibar and sidebar --- public/css/1-userinterface.css | 49 +++++- public/js/navigation/navigation-renderer.js | 42 ++++- .../views/components/module-tabs.blade.php | 166 ++++++++++++++++-- resources/views/layouts/common/tabs.blade.php | 84 +++++---- resources/views/layouts/sidebar.blade.php | 26 +-- resources/views/ui/list.blade.php | 20 +-- src/Http/Controllers/UIController.php | 9 +- 7 files changed, 303 insertions(+), 93 deletions(-) diff --git a/public/css/1-userinterface.css b/public/css/1-userinterface.css index 8915fc3..b7bf947 100644 --- a/public/css/1-userinterface.css +++ b/public/css/1-userinterface.css @@ -5,6 +5,11 @@ z-index: 1000; transition: all 0.3s ease; } + +.sidebar-body { + background-color: var(--bs-body-bg); + min-height: 100%; +} .ui-workarea { padding-top: 0.1px; } @@ -38,8 +43,38 @@ box-shadow: 0 5px 10px rgba(0, 0, 0, 0.3); } -.active-module { - background-color: var(--bs-light); +.sidebar #sidebarMenu .sidebar-active-module { + background-color: var(--bs-primary-bg-subtle) !important; + color: var(--bs-primary-text-emphasis) !important; + font-weight: 400; + border-radius: 0 0.35rem 0.35rem 0; +} + +.sidebar #sidebarMenu .sidebar-active-module:hover { + background-color: var(--bs-primary-bg-subtle) !important; + color: var(--bs-primary-text-emphasis) !important; + border-radius: 0 0.35rem 0.35rem 0; +} + +.sidebar #sidebarMenu .dropdown-item:hover { + background-color: var(--bs-light-bg-subtle); + color: var(--bs-body-color); + border-radius: 0 0.35rem 0.35rem 0; +} + +.minibar .module-tab.minibar-active-module { + background-color: var(--bs-body-bg) !important; + color: var(--bs-primary) !important; + border-radius: 0.5rem; +} + +.minibar .module-tab.minibar-active-module .module-tab-icon-wrapper, +.minibar .module-tab.minibar-active-module .module-tab-label { + color: inherit !important; +} + +.minibar .module-tab:hover { + background-color: rgba(var(--bs-body-color-rgb), 0.08); } /* .main-content { @@ -168,7 +203,7 @@ } .module-tab-mobile .module-tab-label-featured { - font-weight: 600; + font-weight: 400; color: #667eea; } @@ -177,7 +212,7 @@ .module-dropdown-btn-vertical, .module-dropdown-btn-mobile { font-size: 0.875rem; - font-weight: 600; + font-weight: 400; padding: 0.85rem 0.25rem; } @@ -275,7 +310,6 @@ .global-search-action:active { background-color: transparent; border-color: transparent; - box-shadow: none; color: var(--bs-primary); } @@ -488,3 +522,8 @@ ol.breadcrumb { min-width: 40px; max-width: 40px; } + + + + + diff --git a/public/js/navigation/navigation-renderer.js b/public/js/navigation/navigation-renderer.js index 6a70aeb..ac69337 100644 --- a/public/js/navigation/navigation-renderer.js +++ b/public/js/navigation/navigation-renderer.js @@ -70,10 +70,21 @@ return; } + const sortedMenu = [...menu].sort((left, right) => { + const leftOrder = Number.isFinite(Number(left?.sort_order)) ? Number(left.sort_order) : Number.MAX_SAFE_INTEGER; + const rightOrder = Number.isFinite(Number(right?.sort_order)) ? Number(right.sort_order) : Number.MAX_SAFE_INTEGER; + + if (leftOrder === rightOrder) { + return 0; + } + + return leftOrder - rightOrder; + }); + const currentUrl = normalizeUrl(window.location.href); const currentPath = normalizeUrl(window.location.pathname + window.location.search + window.location.hash); - menu.forEach(item => { + sortedMenu.forEach(item => { const link = document.createElement('a'); link.className = 'list-group-item dropdown-item ps-3 pe-2 py-1 d-flex align-items-center'; link.href = item.url || '#'; @@ -81,7 +92,7 @@ const itemUrl = normalizeUrl(item.url); if (itemUrl && (itemUrl === currentUrl || itemUrl === currentPath)) { - link.classList.add('active-module'); + link.classList.add('sidebar-active-module'); link.setAttribute('aria-current', 'page'); } @@ -89,28 +100,47 @@ }); } + function getPrimarySidebarUrl(menu) { + if (!Array.isArray(menu)) { + return ''; + } + + const firstVisibleItem = menu.find(item => item && item.url && item.url !== '#'); + return firstVisibleItem ? firstVisibleItem.url : ''; + } + function setActive(element) { const mode = element.dataset.viewMode; const moduleUid = element.dataset.moduleUid || ''; - getTabs(mode).forEach(t => t.classList.remove('active-module')); + getTabs(mode).forEach(t => t.classList.remove('minibar-active-module')); getDropdownItems(mode).forEach(d => d.classList.remove('active')); - element.classList.add(element.classList.contains('module-tab') ? 'active-module' : 'active'); + element.classList.add(element.classList.contains('module-tab') ? 'minibar-active-module' : 'active'); localStorage.setItem('activeModuleUid', moduleUid); localStorage.setItem('activeModuleIndex', element.dataset.index); } function navigateToModule(element) { const href = element.dataset.href || element.getAttribute('href'); + let menu = []; + + try { + menu = JSON.parse(element.dataset.menu || '[]'); + } catch (e) { + menu = []; + } + + const primarySidebarUrl = getPrimarySidebarUrl(menu); + const targetHref = primarySidebarUrl || href; - if (!href || href === '#' || href.startsWith('javascript:')) { + if (!targetHref || targetHref === '#' || targetHref.startsWith('javascript:')) { return false; } localStorage.setItem('activeModuleUid', element.dataset.moduleUid || ''); localStorage.setItem('activeModuleIndex', element.dataset.index || ''); - window.location.href = href; + window.location.href = targetHref; return true; } diff --git a/resources/views/components/module-tabs.blade.php b/resources/views/components/module-tabs.blade.php index 7bc1ccb..a3c3192 100644 --- a/resources/views/components/module-tabs.blade.php +++ b/resources/views/components/module-tabs.blade.php @@ -19,6 +19,9 @@ $navigationItems = collect(is_array($navigationItems) ? $navigationItems : []) ->filter(fn ($item) => ($item['enabled'] ?? true) && ($item['visible'] ?? true)) + ->sortBy(function ($item) { + return is_numeric($item['sort_order'] ?? null) ? (int) $item['sort_order'] : PHP_INT_MAX; + }) ->values(); if (!Auth::check()) { @@ -150,7 +153,11 @@ $route = $item['route'] ?? null; if ($route && Route::has($route)) { - return route($route, $params); + try { + return route($route, $params); + } catch (\Throwable $e) { + return '#'; + } } if ($module) { @@ -158,7 +165,14 @@ $rawSidebarMenu = is_string($rawSidebarMenu) ? json_decode($rawSidebarMenu, true) : $rawSidebarMenu; $rawSidebarMenu = is_array($rawSidebarMenu) ? $rawSidebarMenu : []; - foreach ($rawSidebarMenu as $sidebarItem) { + $sortedSidebarMenu = collect($rawSidebarMenu) + ->filter(fn ($item) => is_array($item)) + ->sortBy(function ($item) { + return is_numeric($item['sort_order'] ?? null) ? (int) $item['sort_order'] : PHP_INT_MAX; + }) + ->values(); + + foreach ($sortedSidebarMenu as $sidebarItem) { if (!is_array($sidebarItem)) { continue; } @@ -175,7 +189,11 @@ $sidebarRoute = $sidebarItem['route'] ?? null; if ($sidebarRoute && Route::has($sidebarRoute)) { - return route($sidebarRoute, $sidebarParams); + try { + return route($sidebarRoute, $sidebarParams); + } catch (\Throwable $e) { + return '#'; + } } } } @@ -191,31 +209,145 @@ } $route = $item['route'] ?? null; + $url = $item['url'] ?? null; + if (is_string($url) && $url !== '' && $url !== '#') { + $resolvedUrl = $url; + } elseif ($route && Route::has($route)) { + try { + $resolvedUrl = route($route, $params); + } catch (\Throwable $e) { + $resolvedUrl = '#'; + } + } else { + $resolvedUrl = '#'; + } return [ 'icon' => $item['icon'] ?? null, - 'label' => $item['label'] ?? null, - 'url' => $route && Route::has($route) - ? route($route, $params) - : '#', + 'label' => $item['label'] ?? ($item['original_label'] ?? null), + 'url' => $resolvedUrl, ]; }; - $getModuleSidebarItems = function (array $module) use ($resolveModule): array { - $targetModule = $resolveModule($module['target_module_uid'] ?? $module['module_uid'] ?? null); + $resolveSidebarNavigationItems = function (FoundationModule $module): array { + if ($module->name !== 'foundation') { + return []; + } - if (!$targetModule) { + $foundationNavigationRows = Navigation::query() + ->where('ref_parent', $module->id) + ->where('name', 'not like', 'foundation\_navigation') + ->with(['metas' => function ($query) { + $query->where('meta_key', 'navigation_items'); + }]) + ->orderBy('id') + ->get(); + + if ($foundationNavigationRows->isEmpty()) { + $foundationNavigationRows = Navigation::query() + ->where('name', 'foundation_navigation') + ->with(['metas' => function ($query) { + $query->where('meta_key', 'navigation_items'); + }]) + ->get(); + } + + if ($foundationNavigationRows->isEmpty()) { return []; } - $rawSidebarMenu = $targetModule->getMeta('module_sidebar_menu') ?? '[]'; - $rawSidebarMenu = is_string($rawSidebarMenu) ? json_decode($rawSidebarMenu, true) : $rawSidebarMenu; - $rawSidebarMenu = is_array($rawSidebarMenu) ? $rawSidebarMenu : []; + $navigationItemsByNavigationName = $foundationNavigationRows->mapWithKeys(function (Navigation $navigation) { + $items = json_decode($navigation->getMeta('navigation_items') ?? '[]', true); + $items = is_array($items) ? $items : []; + + return [ + $navigation->name => collect($items) + ->filter(fn ($item) => is_array($item)) + ->values() + ->all(), + ]; + }); + + $resolveFirstTabUrl = function (array $sidebarItem) use ($navigationItemsByNavigationName): ?string { + $sidebarId = $sidebarItem['id'] ?? null; - return collect($rawSidebarMenu) - ->filter(fn ($item) => is_array($item) && (($item['enabled'] ?? true) && ($item['visible'] ?? true))) + if (!$sidebarId) { + return null; + } + + foreach ($navigationItemsByNavigationName as $items) { + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + + $placement = $item['placement'] ?? $item['section'] ?? null; + if ($placement !== 'tabs') { + continue; + } + + if (($item['parent_id'] ?? null) !== $sidebarId) { + continue; + } + + $params = $item['params'] ?? []; + if (!empty($item['default-table-schema-uid'])) { + $params['default_table_schema_uid'] = $item['default-table-schema-uid']; + } + + $route = $item['route'] ?? null; + if ($route && Route::has($route)) { + try { + return route($route, $params); + } catch (\Throwable $e) { + return null; + } + } + } + } + + return null; + }; + + $foundationItems = $foundationNavigationRows + ->flatMap(function (Navigation $navigation) { + $items = json_decode($navigation->getMeta('navigation_items') ?? '[]', true); + $items = is_array($items) ? $items : []; + + return collect($items) + ->filter(function ($item) { + return is_array($item) + && (($item['placement'] ?? $item['section'] ?? 'sidebar') === 'sidebar') + && ($item['enabled'] ?? true) + && ($item['visible'] ?? true); + }) + ->map(function (array $item) use ($navigation) { + $item['navigation_name'] = $navigation->name; + return $item; + }); + }) + ->sortBy(function ($item) { + return is_numeric($item['sort_order'] ?? null) ? (int) $item['sort_order'] : PHP_INT_MAX; + }) ->values() + ->map(function (array $item) use ($resolveFirstTabUrl) { + $item['label'] = $item['label'] ?? $item['original_label'] ?? ''; + $item['url'] = $item['url'] ?? $resolveFirstTabUrl($item) ?? null; + return $item; + }) ->all(); + + return $foundationItems; + }; + + $getModuleSidebarItems = function (array $module) use ($resolveModule, $resolveSidebarNavigationItems): array { + $targetModule = $resolveModule($module['target_module_uid'] ?? $module['module_uid'] ?? null); + + if (!$targetModule) { + return []; + } + + return $resolveSidebarNavigationItems($targetModule); }; // View mode configurations @@ -269,7 +401,7 @@ $primaryUrl = $withModuleQuery($resolveNavigationUrl($module, $moduleModel), $moduleUid); $menu = collect($submenuItems) ->map(fn ($item) => is_array($item) ? $buildSidebarMenuItem($item) : []) - ->filter(fn ($item) => !empty($item['url']) && $item['url'] !== '#') + ->filter(fn ($item) => !empty($item['label'])) ->values(); @endphp @@ -321,7 +453,7 @@ class="module-tab module-tab-{{ $viewMode }} {{ $isFeatured ? 'module-tab-featur $primaryUrl = $withModuleQuery($resolveNavigationUrl($module, $moduleModel), $moduleUid); $menu = collect($submenuItems) ->map(fn ($item) => is_array($item) ? $buildSidebarMenuItem($item) : []) - ->filter(fn ($item) => !empty($item['url']) && $item['url'] !== '#') + ->filter(fn ($item) => !empty($item['label'])) ->values(); @endphp
  • diff --git a/resources/views/layouts/common/tabs.blade.php b/resources/views/layouts/common/tabs.blade.php index 3c84e4c..142ac94 100644 --- a/resources/views/layouts/common/tabs.blade.php +++ b/resources/views/layouts/common/tabs.blade.php @@ -1,10 +1,14 @@ @php use Illuminate\Support\Str; + use Iquesters\Foundation\Services\NavigationLayoutService; // Layout-controlled defaults $tabId = 'tabs'; $sticky = false; $secondary = false; + $familyRoute = $familyRoute ?? null; + $navigationTabs = app(NavigationLayoutService::class)->getTabsForCurrentRoute(); + $tabs = $navigationTabs; // Filter tabs user can actually see $visibleTabs = collect($tabs)->filter(function ($tab) { @@ -13,6 +17,14 @@ } return true; + })->filter(function ($tab) use ($familyRoute) { + if (!$familyRoute) { + return true; + } + + $tabFamilyRoute = $tab['family_route'] ?? $tab['sidebar_route'] ?? null; + + return $tabFamilyRoute === null || $tabFamilyRoute === $familyRoute; })->values(); @endphp @@ -27,18 +39,23 @@ class="nav {{ $secondary ? 'nav-pills' : 'nav-tabs' }} @php // Route params (safe) $routeParams = array_filter($tab['params'] ?? [], fn ($v) => !is_null($v)); + $tabFamilyRoute = $tab['family_route'] ?? $tab['sidebar_route'] ?? $familyRoute; // Auto-generate stable tab id $autoTabId = 'tab-' . Str::slug($tab['route'] . '-' . $index); // Server-side active (basic) $isActive = request()->routeIs($tab['route']); + if ($tabFamilyRoute) { + $isActive = $isActive && (request()->routeIs($tabFamilyRoute) || request()->routeIs($tab['route'])); + } @endphp
  • - @endif @@ -68,47 +76,63 @@ class="nav-link d-flex d-lg-block flex-column flex-lg-row align-items-center px- @push('scripts') @endpush -@endonce \ No newline at end of file +@endonce diff --git a/resources/views/layouts/sidebar.blade.php b/resources/views/layouts/sidebar.blade.php index cec60ed..f903a84 100644 --- a/resources/views/layouts/sidebar.blade.php +++ b/resources/views/layouts/sidebar.blade.php @@ -3,8 +3,8 @@ use Iquesters\Foundation\Enums\Module; @endphp -