fillFromConfig([ 'columns', 'model', 'recordUrl', 'recordOnClick', 'noRecordsMessage', 'showPageNumbers', 'showTotals', 'recordsPerPage', 'perPageOptions', 'showSorting', 'defaultSort', 'showCheckboxes', 'showSetup', 'showTree', 'treeExpanded', 'showPagination', 'customViewPath', 'sortable', ]); /* * Configure the list widget */ if ($this->showSetup) { $this->recordsPerPage = $this->getUserPreference('per_page', $this->recordsPerPage); } if ($this->showPagination == 'auto') { $this->showPagination = $this->recordsPerPage && $this->recordsPerPage > 0; } /* * Drag-and-drop reordering shows every record in its stored order. Disable column * header sorting and pagination so the model/relation order is always presented. */ if ($this->sortable) { $this->showSorting = false; $this->showPagination = false; } if ($this->customViewPath) { $this->prependViewPath($this->customViewPath); } $this->validateModel(); $this->validateTree(); } /** * @inheritDoc */ protected function loadAssets() { $this->addJs('js/winter.list.js', 'core'); // loadAssets() runs before init()/fillFromConfig(), so read the raw config value. if ($this->getConfig('sortable', false)) { $this->addJs('js/dist/winter.list.sortable.js', 'core'); $this->addCss('css/winter.list.sortable.css', 'core'); } } /** * Renders the widget. */ public function render() { $this->prepareVars(); return $this->makePartial('list-container'); } /** * Prepares the list data */ public function prepareVars() { $this->vars['cssClasses'] = implode(' ', $this->cssClasses); $this->vars['columns'] = $columns = $this->getVisibleColumns(); $this->vars['columnTotal'] = $this->getTotalColumns(); $this->vars['records'] = $records = $this->getRecords(); $this->vars['noRecordsMessage'] = trans($this->noRecordsMessage); $this->vars['showCheckboxes'] = $this->showCheckboxes; $this->vars['showSetup'] = $this->showSetup; $this->vars['showPagination'] = $this->showPagination; $this->vars['showPageNumbers'] = $this->showPageNumbers; $this->vars['showSorting'] = $this->showSorting; $this->vars['sortColumn'] = $this->getSortColumn(); $this->vars['sortDirection'] = $this->sortDirection; $this->vars['showTree'] = $this->showTree; $this->vars['treeLevel'] = 0; $this->vars['sortable'] = $this->sortable; $this->vars['reorderHandler'] = $this->sortable ? $this->getEventHandler('onReorder') : null; if ($this->showPagination) { $this->vars['pageCurrent'] = $records->currentPage(); // Store the currently visited page number in the session so the same // data can be displayed when the user returns to this list. $this->putSession('lastVisitedPage', $this->vars['pageCurrent']); if ($this->showPageNumbers) { $this->vars['recordTotal'] = $records->total(); $this->vars['pageLast'] = $records->lastPage(); $this->vars['pageFrom'] = $records->firstItem(); $this->vars['pageTo'] = $records->lastItem(); } else { $this->vars['hasMorePages'] = $records->hasMorePages(); } } else { $this->vars['recordTotal'] = $records->count(); $this->vars['pageCurrent'] = 1; } // Disable showTotals if there are no records to display if (!$records->count()) { $this->showTotals = false; } // Initialize sums arrays if ($this->showTotals) { $sums = []; $formats = []; $queryTotals = $this->calculateTotalSums($columns); // Initialize sums to zero for numeric columns foreach ($columns as $column) { if ($column->type === 'number' && $column->summable) { $sums[$column->columnName] = 0; $formats[$column->columnName] = $column->format ?? null; } } if (empty($sums)) { $this->showTotals = false; } else { // Calculate sums for the current page foreach ($records as $record) { foreach ($columns as $column) { if ($column->type === 'number' && $column->summable) { $value = $this->getColumnValueRaw($record, $column); if (is_numeric($value)) { $sums[$column->columnName] += $value; } } } } // Process the column values $this->vars['sums'] = collect($sums)->mapWithKeys(function ($sum, $columnName) use ($queryTotals, $formats) { return [ $columnName => [ 'sum' => $sum, 'total' => $queryTotals[$columnName] ?? null, 'format' => $formats[$columnName] ?? null, ], ]; })->toArray(); } } $this->vars['showTotals'] = $this->showTotals; } /** * Event handler for refreshing the list. */ public function onRefresh() { $this->prepareVars(); return ['#'.$this->getId() => $this->makePartial('list')]; } /** * Event handler for drag-and-drop reordering of records. * * Receives the record ids in their new order and validates that all ids are within the * current query scope, then fires the `list.reorder` event with sequential 1..N sort * order values (assigned server-side by position) for behaviors to persist. */ public function onReorder() { if (!$this->sortable) { throw new ApplicationException('Reordering is not enabled for this list.'); } $ids = post('record_ids'); if (!is_array($ids) || !count($ids)) { return; } /* * Security: only permit reordering records that are visible within the current * query scope. This prevents a crafted request from reordering arbitrary records. */ $allowed = array_flip(array_map('strval', $this->prepareQuery()->pluck($this->model->getQualifiedKeyName())->all())); foreach ($ids as $id) { if (!isset($allowed[(string) $id])) { throw new ApplicationException('One or more records are not available for reordering.'); } } /* * Sort orders are assigned server-side by position; the list always reorders * positionally, so we never trust client-supplied order values. */ $orders = range(1, count($ids)); /** * @event backend.list.reorder * Called when records are reordered via drag-and-drop. Receives the record ids in * their new order and the sort order values to assign to each. * * $listWidget->bindEvent('list.reorder', function ($ids, $orders) { * $model->setSortableOrder($ids, $orders); * }); * */ $this->fireSystemEvent('backend.list.reorder', [$ids, $orders]); return $this->onRefresh(); } /** * Event handler for switching the page number. */ public function onPaginate() { $this->currentPageNumber = post('page'); return $this->onRefresh(); } /** * Event handler for changing the filter */ public function onFilter() { $this->currentPageNumber = 1; return $this->onRefresh(); } /** * Validate the supplied form model. * @return void */ protected function validateModel() { if (!$this->model) { throw new ApplicationException(Lang::get( 'backend::lang.list.missing_model', ['class'=>get_class($this->controller)] )); } if (!$this->model instanceof Model) { throw new ApplicationException(Lang::get( 'backend::lang.model.invalid_class', ['model'=>get_class($this->model), 'class'=>get_class($this->controller)] )); } return $this->model; } /** * Replaces the @ symbol with a table name in a model * @param string $sql * @param string $table * @return string */ protected function parseTableName($sql, $table) { return str_replace('@', $table.'.', $sql); } /** * Applies any filters to the model. */ public function prepareQuery() { $query = $this->model->newQuery(); $primaryTable = $this->model->getTable(); $selects = [$primaryTable.'.*']; $joins = []; $withs = []; $bindings = []; /** * @event backend.list.extendQueryBefore * Provides an opportunity to modify the `$query` object before the List widget applies its scopes to it. * * Example usage: * * Event::listen('backend.list.extendQueryBefore', function ($listWidget, $query) { * $query->whereNull('deleted_at'); * }); * * Or * * $listWidget->bindEvent('list.extendQueryBefore', function ($query) { * $query->whereNull('deleted_at'); * }); * */ $this->fireSystemEvent('backend.list.extendQueryBefore', [$query]); /* * Prepare searchable column names */ $primarySearchable = []; $relationSearchable = []; if ( strlen($this->searchTerm) !== 0 && trim($this->searchTerm) !== '' && ($searchableColumns = $this->getSearchableColumns()) ) { foreach ($searchableColumns as $column) { /* * Related */ if ($this->isColumnRelated($column)) { $table = $this->model->makeRelation($column->relation)->getTable(); $columnName = isset($column->sqlSelect) ? DbDongle::raw($this->parseTableName($column->sqlSelect, $table)) : $table . '.' . $column->valueFrom; $relationSearchable[$column->relation][] = $columnName; } /* * Primary */ else { $columnName = isset($column->sqlSelect) ? DbDongle::raw($this->parseTableName($column->sqlSelect, $primaryTable)) : DbDongle::cast(DB::getTablePrefix() . $primaryTable . '.' . $column->columnName, 'TEXT'); $primarySearchable[] = $columnName; } } } /* * Prepare related eager loads (withs) and custom selects (joins) */ foreach ($this->getVisibleColumns() as $column) { // If useRelationCount is enabled, eager load the count of the relation into $relation_count if ($column->relation && ($column->config['useRelationCount'] ?? false)) { $query->withCount($column->relation); } if (!$this->isColumnRelated($column) || (!isset($column->sqlSelect) && !isset($column->valueFrom))) { continue; } if (isset($column->valueFrom)) { $withs[] = $column->relation; } $joins[] = $column->relation; } /* * Add eager loads to the query */ if ($withs) { $query->with(array_unique($withs)); } /* * Apply search term */ $query->where(function ($innerQuery) use ($primarySearchable, $relationSearchable, $joins) { /* * Search primary columns */ if (count($primarySearchable) > 0) { $this->applySearchToQuery($innerQuery, $primarySearchable, 'or'); } /* * Search relation columns */ if ($joins) { foreach (array_unique($joins) as $join) { /* * Apply a supplied search term for relation columns and * constrain the query only if there is something to search for */ $columnsToSearch = array_get($relationSearchable, $join, []); if (count($columnsToSearch) > 0) { $innerQuery->orWhereHas($join, function ($_query) use ($columnsToSearch) { $this->applySearchToQuery($_query, $columnsToSearch); }); } } } }); /* * Custom select queries */ foreach ($this->getVisibleColumns() as $column) { if (!isset($column->sqlSelect)) { continue; } $alias = $query->getQuery()->getGrammar()->wrap($column->columnName); /* * Relation column */ if (isset($column->relation)) { // @todo Find a way... $relationType = $this->model->getRelationType($column->relation); if ($relationType == 'morphTo') { throw new ApplicationException('The relationship morphTo is not supported for list columns.'); } $table = $this->model->makeRelation($column->relation)->getTable(); $sqlSelect = $this->parseTableName($column->sqlSelect, $table); /* * Manipulate a count query for the sub query */ $relationObj = $this->model->{$column->relation}(); $countQuery = $relationObj->getRelationExistenceQuery($relationObj->getRelated()->newQueryWithoutScopes(), $query); $limit = $column->config['limit'] ?? false; $joinSql = $this->isColumnRelated($column, true) && $limit !== 1 ? DbDongle::raw("group_concat(" . $sqlSelect . " separator ', ')") : DbDongle::raw($sqlSelect); $joinQuery = $countQuery->select($joinSql); if (!empty($column->config['conditions'])) { $joinQuery->whereRaw(DbDongle::parse($column->config['conditions'])); } if ($limit) { $joinQuery->limit($column->config['limit']); } $joinSql = $joinQuery->toSql(); $selects[] = DB::raw("(" . $joinSql . ") as " . $alias); /* * If this is a polymorphic relation there will be bindings that need to be added to the query */ $bindings = array_merge($bindings, $countQuery->getBindings()); } /* * Primary column */ else { $sqlSelect = $this->parseTableName($column->sqlSelect, $primaryTable); $selects[] = DbDongle::raw($sqlSelect . ' as '. $alias); } } /* * Apply sorting */ if (($sortColumn = $this->getSortColumn()) && !$this->showTree) { if (($column = array_get($this->allColumns, $sortColumn)) && $column->valueFrom) { $sortColumn = $this->isColumnPivot($column) ? 'pivot_' . $column->valueFrom : $column->valueFrom; } // Set the sorting column to $relation_count if useRelationCount enabled if (isset($column->relation) && ($column->config['useRelationCount'] ?? false)) { $sortColumn = Str::snake($column->relation) . '_count'; } $query->orderBy($sortColumn, $this->sortDirection); } /* * Apply filters */ foreach ($this->filterCallbacks as $callback) { $callback($query); } /* * Add custom selects */ $query->addSelect($selects); /* * Add bindings for polymorphic relations */ $query->addBinding($bindings, 'select'); /** * @event backend.list.extendQuery * Provides an opportunity to modify and / or return the `$query` object after the List widget has applied its scopes to it and before it's used to get the records. * * Example usage: * * Event::listen('backend.list.extendQuery', function ($listWidget, $query) { * $newQuery = MyModel::newQuery(); * return $newQuery; * }); * * Or * * $listWidget->bindEvent('list.extendQuery', function ($query) { * $query->whereNull('deleted_at'); * }); * */ if ($event = $this->fireSystemEvent('backend.list.extendQuery', [$query])) { return $event; } return $query; } /** * Calculate the totals for the summable columns */ protected function calculateTotalSums(array $columns): array { $sums = []; $query = $this->prepareQuery(); // Build an array of numeric columns to sum $sumColumns = []; foreach ($columns as $column) { if ($column->type === 'number' && $column->summable) { $columnName = $column->columnName; $sumColumns[$columnName] = $column; $sums[$columnName] = 0; } } if (empty($sums)) { return []; } // Modify the query to select the sums $query->getQuery()->columns = []; foreach ($sumColumns as $alias => $column) { // Handle columns with custom select if (isset($column->sqlSelect)) { $sqlSelect = $column->sqlSelect; $sumExpression = "SUM({$sqlSelect}) as {$alias}"; $query->addSelect(DB::raw($sumExpression)); } else { $columnName = $column->columnName; $sumExpression = "SUM({$columnName}) as {$alias}"; $query->addSelect(DB::raw($sumExpression)); } } // Remove any ordering to optimize performance $query->getQuery()->orders = null; // Get the sums try { $result = $query->first(); } catch (QueryException $ex) { traceLog("Lists widget: showTotals query totals disabled due to SQL error", $ex); return []; } // Assign the sums to the $sums array foreach ($sumColumns as $alias => $column) { $sums[$alias] = $result->$alias ?? 0; } return $sums; } public function prepareModel() { traceLog('Method ' . __METHOD__ . '() has been deprecated, please use the ' . __CLASS__ . '::prepareQuery() method instead.'); return $this->prepareQuery(); } /** * Returns all the records from the supplied model, after filtering. * @return Collection */ protected function getRecords() { $query = $this->prepareQuery(); if ($this->showTree) { $records = $query->getNested(); } elseif ($this->showPagination) { $method = $this->showPageNumbers ? 'paginate' : 'simplePaginate'; $currentPageNumber = $this->getCurrentPageNumber($query); $records = $query->{$method}($this->recordsPerPage, $currentPageNumber); } else { $records = $query->get(); } /** * @event backend.list.extendRecords * Provides an opportunity to modify and / or return the `$records` Collection object before the widget uses it. * * Example usage: * * Event::listen('backend.list.extendRecords', function ($listWidget, $records) { * $model = MyModel::where('always_include', true)->first(); * $records->prepend($model); * }); * * Or * * $listWidget->bindEvent('list.extendRecords', function ($records) { * $model = MyModel::where('always_include', true)->first(); * $records->prepend($model); * }); * */ if ($event = $this->fireSystemEvent('backend.list.extendRecords', [&$records])) { $records = $event; } return $this->records = $records; } /** * Returns the current page number for the list. * * This will override the current page number provided by the user if it is past the last page of available records. * * @param object $query * @return int */ protected function getCurrentPageNumber($query) { $currentPageNumber = $this->currentPageNumber; if (empty($currentPageNumber)) { $currentPageNumber = $this->getSession('lastVisitedPage'); } $currentPageNumber = intval($currentPageNumber); if ($currentPageNumber > 1) { $count = $query->count(); // If the current page number is higher than the amount of available pages, go to the last available page if ($count <= (($currentPageNumber - 1) * $this->recordsPerPage)) { $currentPageNumber = ceil($count / $this->recordsPerPage); } } return $currentPageNumber; } /** * Returns the record URL address for a list row. * @param Model $record * @return string */ public function getRecordUrl($record) { if (isset($this->recordOnClick)) { return 'javascript:;'; } if (!isset($this->recordUrl)) { return null; } $url = RouterHelper::replaceParameters($record, $this->recordUrl); // Allow external or relative URLs if (!Str::startsWith($url, ['http', '/'])) { $url = Backend::url($url); } return $url; } /** * Returns the onclick event for a list row. * @param Model $record * @return string */ public function getRecordOnClick($record) { if (!isset($this->recordOnClick)) { return null; } $recordOnClick = RouterHelper::replaceParameters($record, $this->recordOnClick); return Html::attributes(['onclick' => $recordOnClick]); } /** * Get all the registered columns for the instance. * @return array */ public function getColumns() { return $this->allColumns ?: $this->defineListColumns(); } /** * Get a specified column object * @param string $column * @return mixed */ public function getColumn($column) { if (!isset($this->allColumns[$column])) { throw new ApplicationException('No definition for column ' . $column); } return $this->allColumns[$column]; } /** * Returns the list columns that are visible by list settings or default */ public function getVisibleColumns() { $definitions = $this->defineListColumns(); $columns = []; /* * Supplied column list */ if ($this->showSetup && $this->columnOverride === null) { $this->columnOverride = $this->getUserPreference('visible', null); } if ($this->columnOverride && is_array($this->columnOverride)) { $invalidColumns = array_diff($this->columnOverride, array_keys($definitions)); if (!count($definitions)) { throw new ApplicationException(Lang::get( 'backend::lang.list.missing_column', ['columns'=>implode(',', $invalidColumns)] )); } $availableColumns = array_intersect($this->columnOverride, array_keys($definitions)); foreach ($availableColumns as $columnName) { $definitions[$columnName]->invisible = false; $columns[$columnName] = $definitions[$columnName]; } } /* * Use default column list */ else { foreach ($definitions as $columnName => $column) { if ($column->invisible) { continue; } $columns[$columnName] = $definitions[$columnName]; } } return $this->visibleColumns = $columns; } /** * Builds an array of list columns with keys as the column name and values as a ListColumn object. */ protected function defineListColumns() { if (!isset($this->columns) || !is_array($this->columns) || !count($this->columns)) { $class = get_class($this->model instanceof Model ? $this->model : $this->controller); throw new ApplicationException(Lang::get('backend::lang.list.missing_columns', compact('class'))); } /** * @event backend.list.extendColumnsBefore * Provides an opportunity to modify the columns of a List widget before the columns are created. * * Example usage: * * Event::listen('backend.list.extendColumnsBefore', function ($listWidget) { * // Only for the User controller * if (!$listWidget->getController() instanceof \Backend\Controllers\Users) { * return; * } * * // Only for the User model * if (!$listWidget->model instanceof \Backend\Models\User) { * return; * } * * // Add a column in first position * $listWidget->columns = array_merge([ * 'myColumn' => [ * 'type' => 'text', * 'label' => 'My Column', * ], * ], $listWidget->columns); * }); * * Or * * $listWidget->bindEvent('list.extendColumnsBefore', function () use ($listWidget) { * // Only for the User controller * if (!$listWidget->getController() instanceof \Backend\Controllers\Users) { * return; * } * * // Only for the User model * if (!$listWidget->model instanceof \Backend\Models\User) { * return; * } * * // Add a column in first position * $listWidget->columns = array_merge([ * 'myColumn' => [ * 'type' => 'text', * 'label' => 'My Column', * ], * ], $listWidget->columns); * }); * */ $this->fireSystemEvent('backend.list.extendColumnsBefore'); $this->addColumns($this->columns); /** * @event backend.list.extendColumns * Provides an opportunity to modify the columns of a List widget * * Example usage: * * Event::listen('backend.list.extendColumns', function ($listWidget) { * // Only for the User controller * if (!$listWidget->getController() instanceof \Backend\Controllers\Users) { * return; * } * * // Only for the User model * if (!$listWidget->model instanceof \Backend\Models\User) { * return; * } * * // Add an extra birthday column * $listWidget->addColumns([ * 'birthday' => [ * 'label' => 'Birthday' * ] * ]); * * // Remove a Surname column * $listWidget->removeColumn('surname'); * }); * * Or * * $listWidget->bindEvent('list.extendColumns', function () use ($listWidget) { * // Only for the User controller * if (!$listWidget->getController() instanceof \Backend\Controllers\Users) { * return; * } * * // Only for the User model * if (!$listWidget->model instanceof \Backend\Models\User) { * return; * } * * // Add an extra birthday column * $listWidget->addColumns([ * 'birthday' => [ * 'label' => 'Birthday' * ] * ]); * * // Remove a Surname column * $listWidget->removeColumn('surname'); * }); * */ $this->fireSystemEvent('backend.list.extendColumns'); /* * Use a supplied column order */ if ($columnOrder = $this->getUserPreference('order', null)) { $orderedDefinitions = []; foreach ($columnOrder as $column) { if (isset($this->allColumns[$column])) { $orderedDefinitions[$column] = $this->allColumns[$column]; } } $this->allColumns = array_merge($orderedDefinitions, $this->allColumns); } /* * When drag-and-drop reordering is enabled, disable sorting on every column so * getSortColumn() returns false. This keeps the widget from applying its own * orderBy() and preserves the model/relation's stored sort order (it also stops * RelationController from clearing the relation order when a sort column is set). */ if ($this->sortable) { foreach ($this->allColumns as $column) { $column->sortable = false; } } return $this->allColumns; } /** * Programatically add columns, used internally and for extensibility. * @param array $columns Column definitions */ public function addColumns(array $columns) { /* * Build a final collection of list column objects */ foreach ($columns as $columnName => $config) { // Check if user has permissions to show this column $permissions = array_get($config, 'permissions'); if (!empty($permissions) && !BackendAuth::getUser()->hasAccess($permissions, false)) { continue; } $this->allColumns[$columnName] = $this->makeListColumn($columnName, $config); } } /** * Programatically remove a column, used for extensibility. * @param string $column Column name */ public function removeColumn($columnName) { if (isset($this->allColumns[$columnName])) { unset($this->allColumns[$columnName]); } } /** * Creates a list column object from it's name and configuration. */ protected function makeListColumn($name, $config) { if (is_string($config)) { $label = $config; } elseif (isset($config['label'])) { $label = $config['label']; } else { $label = studly_case($name); } /* * Auto configure pivot relation */ if (starts_with($name, 'pivot[') && strpos($name, ']') !== false) { $_name = HtmlHelper::nameToArray($name); $relationName = array_shift($_name); $valueFrom = array_shift($_name); if (count($_name) > 0) { $valueFrom .= '['.implode('][', $_name).']'; } $config['relation'] = $relationName; $config['valueFrom'] = $valueFrom; $config['searchable'] = false; } /* * Auto configure standard relation */ elseif (strpos($name, '[') !== false && strpos($name, ']') !== false) { $config['valueFrom'] = $name; $config['sortable'] = false; $config['searchable'] = false; } $columnType = $config['type'] ?? null; $column = new ListColumn($name, $label); $column->displayAs($columnType, $config); return $column; } /** * Calculates the total columns used in the list, including checkboxes * and other additions. */ protected function getTotalColumns() { $columns = $this->visibleColumns ?: $this->getVisibleColumns(); $total = count($columns); if ($this->showCheckboxes) { $total++; } if ($this->showSetup) { $total++; } if ($this->showTree) { $total++; } if ($this->sortable) { $total++; } return $total; } /** * Looks up the column header */ public function getHeaderValue($column) { $value = Lang::get($column->label); /** * @event backend.list.overrideHeaderValue * Overrides the column header value in a list widget. * * If a value is returned from this event, it will be used as the value for the provided column. * `$value` is passed by reference so modifying the variable in place is also supported. Example usage: * * Event::listen('backend.list.overrideHeaderValue', function ($listWidget, $column, &$value) { * $value .= '-modified'; * }); * * Or * * $listWidget->bindEvent('list.overrideHeaderValue', function ($column, $value) { * return 'Custom header value'; * }); * */ if ($response = $this->fireSystemEvent('backend.list.overrideHeaderValue', [$column, &$value])) { $value = $response; } return $value; } /** * Returns a raw column value * @return string */ public function getColumnValueRaw($record, $column) { $columnName = $column->columnName; /* * Handle taking value from model relation. */ if ($column->valueFrom && $column->relation) { $columnName = $column->relation; if (!array_key_exists($columnName, $record->getRelations())) { $value = null; } elseif ($this->isColumnRelated($column, true)) { $value = $record->{$columnName}->lists($column->valueFrom); } elseif ($this->isColumnRelated($column) || $this->isColumnPivot($column)) { $value = $record->{$columnName} ? $column->getValueFromData($record->{$columnName}) : null; } else { $value = null; } } /* * Handle taking value from model attribute. */ elseif ($column->valueFrom) { $value = $column->getValueFromData($record); } /* * Otherwise, if the column is a relation, it will be a custom select, * so prevent the Model from attempting to load the relation * if the value is NULL. */ else { if ($record->hasRelation($columnName) && array_key_exists($columnName, $record->attributes)) { $value = $record->attributes[$columnName]; // Load the value from the relationship counter if useRelationCount is specified } elseif ($column->relation && ($column->config['useRelationCount'] ?? false)) { $relation = Str::snake($column->relation); $value = $record->{"{$relation}_count"}; } else { $value = $record->{$columnName}; } } if ($value instanceof \BackedEnum) { $value = $value->value; } /** * @event backend.list.overrideColumnValueRaw * Overrides the raw column value in a list widget. * * If a value is returned from this event, it will be used as the raw value for the provided column. * `$value` is passed by reference so modifying the variable in place is also supported. Example usage: * * Event::listen('backend.list.overrideColumnValueRaw', function ($listWidget, $record, $column, &$value) { * $value .= '-modified'; * }); * * Or * * $listWidget->bindEvent('list.overrideColumnValueRaw', function ($record, $column, $value) { * return 'No values for you!'; * }); * */ if ($response = $this->fireSystemEvent('backend.list.overrideColumnValueRaw', [$record, $column, &$value])) { $value = $response; } return $value; } /** * Returns a column value, with filters applied * @return string */ public function getColumnValue($record, $column) { $value = $this->getColumnValueRaw($record, $column); $customMethod = 'eval'. studly_case($column->type) .'TypeValue'; if ($this->methodExists($customMethod)) { $value = $this->{$customMethod}($record, $column, $value); } else { $value = $this->evalCustomListType($column->type, $record, $column, $value); } /* * Apply default value. */ if (($value === '' || is_null($value)) && !empty($column->defaults)) { $value = Lang::get($column->defaults); } /** * @event backend.list.overrideColumnValue * Overrides the column value in a list widget. * * If a value is returned from this event, it will be used as the value for the provided column. * `$value` is passed by reference so modifying the variable in place is also supported. Example usage: * * Event::listen('backend.list.overrideColumnValue', function ($listWidget, $record, $column, &$value) { * $value .= '-modified'; * }); * * Or * * $listWidget->bindEvent('list.overrideColumnValue', function ($record, $column, $value) { * return 'No values for you!'; * }); * */ if ($response = $this->fireSystemEvent('backend.list.overrideColumnValue', [$record, $column, &$value])) { $value = $response; } return $value; } /** * Adds a custom CSS class string to a record row * @param Model $record Populated model * @return string */ public function getRowClass($record) { $value = ''; /** * @event backend.list.injectRowClass * Provides opportunity to inject a custom CSS row class * * If a value is returned from this event, it will be used as the value for the row class. * `$value` is passed by reference so modifying the variable in place is also supported. Example usage: * * Event::listen('backend.list.injectRowClass', function ($listWidget, $record, &$value) { * $value .= '-modified'; * }); * * Or * * $listWidget->bindEvent('list.injectRowClass', function ($record, $value) { * return 'strike'; * }); * */ if ($response = $this->fireSystemEvent('backend.list.injectRowClass', [$record, &$value])) { $value = $response; } return $value; } // // Value processing // /** * Process a custom list types registered by plugins. */ protected function evalCustomListType($type, $record, $column, $value) { $plugins = PluginManager::instance()->getRegistrationMethodValues('registerListColumnTypes'); foreach ($plugins as $availableTypes) { if (!isset($availableTypes[$type])) { continue; } $callback = $availableTypes[$type]; if (is_callable($callback)) { return call_user_func_array($callback, [$value, $column, $record]); } } $customMessage = ''; if ($type === 'relation') { $customMessage = 'Type: relation is not supported, instead use the relation property to specify a relationship to pull the value from and set the type to the type of the value expected.'; } throw new ApplicationException(sprintf('List column type "%s" could not be found. %s', $type, $customMessage)); } /** * Process as text, escape the value * @return string */ protected function evalTextTypeValue($record, $column, $value) { if (is_array($value) && count($value) == count($value, COUNT_RECURSIVE)) { $value = implode(', ', $value); } if (is_string($column->format) && !empty($column->format)) { $value = sprintf($column->format, $value); } return htmlentities($value, ENT_QUOTES, 'UTF-8', false); } /** * Process an image value * @return string */ protected function evalImageTypeValue($record, $column, $value) { $image = null; $config = $column->config; // Get config options with defaults $width = isset($config['width']) ? $config['width'] : 50; $height = isset($config['height']) ? $config['height'] : 50; $options = isset($config['options']) ? $config['options'] : []; $fallback = isset($config['default']) ? $config['default'] : null; // Handle attachMany relationships if (isset($record->attachMany[$column->columnName])) { $image = $value->first(); // Handle attachOne relationships } elseif (isset($record->attachOne[$column->columnName])) { $image = $value; // Handle absolute URLs } elseif (str_contains($value, '://')) { $image = $value; // Handle embedded data URLs } elseif (starts_with($value, 'data:image')) { $image = $value; // Assume all other values to be from the media library } elseif (!empty($value)) { $image = MediaLibrary::url($value); } if (!$image && $fallback) { $image = $fallback; } if ($image) { // filterGetUrl() returns the value it was given when the image cannot be // resolved, so the result may still be the record's raw value. $imageUrl = ImageResizer::filterGetUrl($image, $width, $height, $options); return sprintf( "", e($imageUrl), e($width), e($height) ); } } /** * Process as number, proxy to text * @return string */ protected function evalNumberTypeValue($record, $column, $value) { return $this->evalTextTypeValue($record, $column, $value); } /** * Process as partial reference */ protected function evalPartialTypeValue($record, $column, $value) { return $this->controller->makePartial($column->path ?: $column->columnName, [ 'listColumn' => $column, 'listRecord' => $record, 'listValue' => $value, 'column' => $column, 'record' => $record, 'value' => $value ]); } /** * Process as boolean switch */ protected function evalSwitchTypeValue($record, $column, $value) { $contents = ''; if ($value) { $contents = Lang::get('backend::lang.list.column_switch_true'); } else { $contents = Lang::get('backend::lang.list.column_switch_false'); } return $contents; } /** * Process as a datetime value */ protected function evalDatetimeTypeValue($record, $column, $value) { if ($value === null) { return null; } $dateTime = $this->validateDateTimeValue($value, $column); if ($column->format !== null) { $value = $dateTime->format($column->format); } else { $value = $dateTime->toDayDateTimeString(); } $options = [ 'defaultValue' => $value, 'format' => $column->format, 'formatAlias' => 'dateTimeLongMin' ]; if (!empty($column->config['ignoreTimezone'])) { $options['ignoreTimezone'] = true; } return Backend::dateTime($dateTime, $options); } /** * Process as a time value */ protected function evalTimeTypeValue($record, $column, $value) { if ($value === null) { return null; } $dateTime = $this->validateDateTimeValue($value, $column); $format = $column->format ?? 'g:i A'; $value = $dateTime->format($format); $options = [ 'defaultValue' => $value, 'format' => $column->format, 'formatAlias' => 'time' ]; if (!empty($column->config['ignoreTimezone'])) { $options['ignoreTimezone'] = true; } return Backend::dateTime($dateTime, $options); } /** * Process as a date value */ protected function evalDateTypeValue($record, $column, $value) { if ($value === null) { return null; } $dateTime = $this->validateDateTimeValue($value, $column); if ($column->format !== null) { $value = $dateTime->format($column->format); } else { $value = $dateTime->toFormattedDateString(); } $options = [ 'defaultValue' => $value, 'format' => $column->format, 'formatAlias' => 'dateLongMin', 'ignoreTimezone' => true, ]; if (isset($column->config['ignoreTimezone'])) { $options['ignoreTimezone'] = $column->config['ignoreTimezone']; } return Backend::dateTime($dateTime, $options); } /** * Process as diff for humans (1 min ago) */ protected function evalTimesinceTypeValue($record, $column, $value) { if ($value === null) { return null; } $dateTime = $this->validateDateTimeValue($value, $column); $value = DateTimeHelper::timeSince($dateTime); $options = [ 'defaultValue' => $value, 'timeSince' => true ]; if (!empty($column->config['ignoreTimezone'])) { $options['ignoreTimezone'] = true; } return Backend::dateTime($dateTime, $options); } /** * Process as time as current tense (Today at 0:00) */ protected function evalTimetenseTypeValue($record, $column, $value) { if ($value === null) { return null; } $dateTime = $this->validateDateTimeValue($value, $column); $value = DateTimeHelper::timeTense($dateTime); $options = [ 'defaultValue' => $value, 'timeTense' => true ]; if (!empty($column->config['ignoreTimezone'])) { $options['ignoreTimezone'] = true; } return Backend::dateTime($dateTime, $options); } /** * Process as background color, to be seen at list */ protected function evalColorPickerTypeValue($record, $column, $value) { return ''; } /** * Validates a column type as a date */ protected function validateDateTimeValue($value, $column) { $value = DateTimeHelper::makeCarbon($value, false); if (!$value instanceof Carbon) { throw new ApplicationException(Lang::get( 'backend::lang.list.invalid_column_datetime', ['column' => $column->columnName] )); } return $value; } // // Filtering // public function addFilter(callable $filter) { $this->filterCallbacks[] = $filter; } // // Searching // /** * Applies a search term to the list results, searching will disable tree * view if a value is supplied. * @param string $term * @param boolean $resetPagination */ public function setSearchTerm($term, $resetPagination = false) { if ( strlen($term) !== 0 && trim($term) !== '' ) { if ($this->showTree === true) { // save initial list config showTree value $this->putSession('showTree', true); } $this->showTree = false; } else { if ($this->getSession('showTree')) { // restore initial list config showTree value $this->showTree = true; } } if ($resetPagination) { $this->currentPageNumber = 1; } $this->searchTerm = $term; } /** * Applies a search options to the list search. * @param array $options */ public function setSearchOptions($options = []) { extract(array_merge([ 'mode' => null, 'scope' => null ], $options)); $this->searchMode = $mode; $this->searchScope = $scope; } /** * Returns a collection of columns which can be searched. * @return array */ protected function getSearchableColumns() { $columns = $this->getColumns(); $searchable = []; foreach ($columns as $column) { if (!$column->searchable) { continue; } $searchable[] = $column; } return $searchable; } /** * Applies the search constraint to a query. */ protected function applySearchToQuery($query, $columns, $boolean = 'and') { $term = $this->searchTerm; if ($scopeMethod = $this->searchScope) { $searchMethod = $boolean == 'and' ? 'where' : 'orWhere'; $query->$searchMethod(function ($q) use ($term, $columns, $scopeMethod) { $q->$scopeMethod($term, $columns); }); } else { $searchMethod = $boolean == 'and' ? 'searchWhere' : 'orSearchWhere'; $query->$searchMethod($term, $columns, $this->searchMode); } } // // Sorting // /** * Event handler for sorting the list. */ public function onSort() { if ($column = post('sortColumn')) { /* * Toggle the sort direction and set the sorting column */ $sortOptions = ['column' => $this->getSortColumn(), 'direction' => $this->sortDirection]; if ($column != $sortOptions['column'] || $sortOptions['direction'] == 'asc') { $this->sortDirection = $sortOptions['direction'] = 'desc'; } else { $this->sortDirection = $sortOptions['direction'] = 'asc'; } $this->sortColumn = $sortOptions['column'] = $column; /* * Persist the page number */ $this->currentPageNumber = post('page'); /* * Try to refresh the list with the new sortOptions. Put the * new sortOptions in to the session if the query succeeded. */ $result = $this->onRefresh(); $this->putSession('sort', $sortOptions); return $result; } } /** * Sets the column and direction to sort the list by. * Use the $persist flag to control whether or not the parameters are stored in the session. Defaults to true. */ public function setSort(string $column, string $direction = 'asc', bool $persist = true) { $this->sortColumn = $column; $this->sortDirection = $direction; if ($persist) { $this->putSession('sort', [ 'column' => $this->sortColumn, 'direction' => $this->sortDirection, ]); } } /** * Returns the current sorting column, saved in a session or cached. */ public function getSortColumn() { if (!$this->isSortable()) { return false; } if ($this->sortColumn !== null && $this->isSortable($this->sortColumn)) { return $this->sortColumn; } /* * User preference */ if ($this->showSorting && ($sortOptions = $this->getSession('sort'))) { $this->sortColumn = $sortOptions['column']; $this->sortDirection = $sortOptions['direction']; } /* * Supplied default */ else { if (is_string($this->defaultSort)) { $this->sortColumn = $this->defaultSort; $this->sortDirection = 'desc'; } elseif (is_array($this->defaultSort) && isset($this->defaultSort['column'])) { $this->sortColumn = $this->defaultSort['column']; $this->sortDirection = $this->defaultSort['direction'] ?? 'desc'; } } /* * First available column */ if ($this->sortColumn === null || !$this->isSortable($this->sortColumn)) { $columns = $this->visibleColumns ?: $this->getVisibleColumns(); $columns = array_filter($columns, function ($column) { return $column->sortable; }); $this->sortColumn = key($columns); $this->sortDirection = 'desc'; } return $this->sortColumn; } /* * Returns the current sort direction or default of 'asc' */ public function getSortDirection() { return $this->sortDirection ?? 'asc'; } /** * Returns true if the column can be sorted. */ protected function isSortable($column = null) { if ($column === null) { return (count($this->getSortableColumns()) > 0); } return array_key_exists($column, $this->getSortableColumns()); } /** * Returns a collection of columns which are sortable. */ protected function getSortableColumns() { if ($this->sortableColumns !== null) { return $this->sortableColumns; } $columns = $this->getColumns(); $sortable = array_filter($columns, function ($column) { return $column->sortable; }); return $this->sortableColumns = $sortable; } // // List Setup // /** * Event handler to display the list set up. */ public function onLoadSetup() { $this->vars['columns'] = $this->getSetupListColumns(); $this->vars['perPageOptions'] = $this->getSetupPerPageOptions(); $this->vars['recordsPerPage'] = $this->recordsPerPage; return $this->makePartial('setup_form'); } /** * Event handler to apply the list set up. */ public function onApplySetup() { if (($visibleColumns = post('visible_columns')) && is_array($visibleColumns)) { $this->columnOverride = $visibleColumns; $this->putUserPreference('visible', $this->columnOverride); } $this->recordsPerPage = post('records_per_page', $this->recordsPerPage); $this->putUserPreference('order', post('column_order')); $this->putUserPreference('per_page', $this->recordsPerPage); return $this->onRefresh(); } /** * Event handler to apply the list set up. */ public function onResetSetup() { $this->clearUserPreference('order'); $this->clearUserPreference('visible'); $this->clearUserPreference('per_page'); return $this->onRefresh(); } /** * Returns an array of allowable records per page. */ protected function getSetupPerPageOptions() { $perPageOptions = is_array($this->perPageOptions) ? $this->perPageOptions : [20, 40, 80, 100, 120]; if (!in_array($this->recordsPerPage, $perPageOptions)) { $perPageOptions[] = $this->recordsPerPage; } sort($perPageOptions); return $perPageOptions; } /** * Returns all the list columns used for list set up. */ protected function getSetupListColumns() { /* * Force all columns invisible */ $columns = $this->defineListColumns(); foreach ($columns as $column) { $column->invisible = true; } return array_merge($columns, $this->getVisibleColumns()); } // // Tree // /** * Validates the model and settings if showTree is used * @return void */ public function validateTree() { if (!$this->showTree) { return; } $this->showSorting = $this->showPagination = false; if (!$this->model->methodExists('getChildren')) { throw new ApplicationException( 'To display list as a tree, the specified model must have a method "getChildren"' ); } if (!$this->model->methodExists('getChildCount')) { throw new ApplicationException( 'To display list as a tree, the specified model must have a method "getChildCount"' ); } } /** * Checks if a node (model) is expanded in the session. * @param Model $node * @return boolean */ public function isTreeNodeExpanded($node) { return $this->getSession('tree_node_status_' . $node->getKey(), $this->treeExpanded); } /** * Sets a node (model) to an expanded or collapsed state, stored in the * session, then renders the list again. * @return string List HTML contents. */ public function onToggleTreeNode() { $this->putSession('tree_node_status_' . post('node_id'), post('status') ? 0 : 1); return $this->onRefresh(); } // // Helpers // /** * Check if column refers to a relation of the model * @param boolean $multi If set, returns true only if the relation is a "multiple relation type" * @return boolean */ protected function isColumnRelated(ListColumn $column, bool $multi = false): bool { if (!isset($column->relation) || $this->isColumnPivot($column)) { return false; } if (!$this->model->hasRelation($column->relation)) { throw new ApplicationException(Lang::get( 'backend::lang.model.missing_relation', ['class'=>get_class($this->model), 'relation'=>$column->relation] )); } if (!$multi) { return true; } $relationType = $this->model->getRelationType($column->relation); return in_array($relationType, [ 'hasMany', 'belongsToMany', 'morphToMany', 'morphedByMany', 'morphMany', 'attachMany', 'hasManyThrough' ]); } /** * Checks if a column refers to a pivot model specifically. * @param ListColumn $column List column object * @return boolean */ protected function isColumnPivot($column) { if (!isset($column->relation) || $column->relation != 'pivot') { return false; } return true; } }