Merge remote-tracking branch 'origin/develop'

This commit is contained in:
snipe
2026-03-27 21:10:15 +00:00
46 changed files with 637 additions and 1022 deletions
@@ -70,20 +70,9 @@ class AccessoriesController extends Controller
->with('category', 'company', 'manufacturer', 'checkouts', 'location', 'supplier', 'adminuser')
->withCount('checkouts as checkouts_count');
$filter = [];
if ($request->filled('filter')) {
$filter = json_decode($request->input('filter'), true);
$filter = array_filter($filter, function ($key) use ($allowed_columns) {
return in_array($key, $allowed_columns);
}, ARRAY_FILTER_USE_KEY);
}
if ((! is_null($filter)) && (count($filter)) > 0) {
$accessories->ByFilter($filter);
} elseif ($request->filled('search')) {
$accessories->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$accessories->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->filled('company_id')) {
@@ -93,21 +93,9 @@ class AssetModelsController extends Controller
->withCount('assignedAssets as assets_assigned_count')
->withCount('archivedAssets as assets_archived_count');
$filter = [];
if ($request->filled('filter')) {
$filter = json_decode($request->input('filter'), true);
$filter = array_filter($filter, function ($key) use ($allowed_columns) {
return in_array($key, $allowed_columns);
}, ARRAY_FILTER_USE_KEY);
}
if ((! is_null($filter)) && (count($filter)) > 0) {
$assetmodels->ByFilter($filter);
} elseif ($request->filled('search')) {
$assetmodels->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$assetmodels->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->input('status') == 'deleted') {
+3 -15
View File
@@ -142,17 +142,6 @@ class AssetsController extends Controller
$allowed_columns[] = $field->db_column_name();
}
$filter = [];
if ($request->filled('filter')) {
$filter = json_decode($request->input('filter'), true);
$filter = array_filter($filter, function ($key) use ($allowed_columns) {
return in_array($key, $allowed_columns);
}, ARRAY_FILTER_USE_KEY);
}
$assets = Asset::select('assets.*')
// ->addSelect([
// 'first_checkout_at' => Actionlog::query()
@@ -195,10 +184,9 @@ class AssetsController extends Controller
}
}
if ((! is_null($filter)) && (count($filter)) > 0) {
$assets->ByFilter($filter);
} elseif ($request->filled('search')) {
$assets->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$assets->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
/**
@@ -6,6 +6,7 @@ use App\Actions\Categories\DestroyCategoryAction;
use App\Exceptions\ItemStillHasChildren;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Transformers\CategoriesTransformer;
use App\Http\Transformers\SelectlistTransformer;
@@ -26,62 +27,50 @@ class CategoriesController extends Controller
*
* @return Response
*/
public function index(Request $request): array
public function index(FilterRequest $request): array
{
$this->authorize('view', Category::class);
$allowed_columns = [
'id',
'name',
'category_type',
'category_type',
'use_default_eula',
'eula_text',
'require_acceptance',
'checkin_email',
'assets_count',
'accessories_count',
'consumables_count',
'assets_count',
'category_type',
'checkin_email',
'components_count',
'licenses_count',
'consumables_count',
'created_at',
'updated_at',
'eula_text',
'id',
'image',
'tag_color',
'licenses_count',
'name',
'notes',
'require_acceptance',
'tag_color',
'updated_at',
'use_default_eula',
];
$categories = Category::select([
'id',
'created_by',
'created_at',
'updated_at',
'name', 'category_type',
'use_default_eula',
'eula_text',
'require_acceptance',
'category_type',
'checkin_email',
'created_at',
'created_by',
'eula_text',
'id',
'image',
'tag_color',
'name',
'notes',
'require_acceptance',
'tag_color',
'updated_at',
'use_default_eula',
])
->with('adminuser')
->withCount('accessories as accessories_count', 'consumables as consumables_count', 'components as components_count', 'licenses as licenses_count', 'models as models_count');
$filter = [];
if ($request->filled('filter')) {
$filter = json_decode($request->input('filter'), true);
$filter = array_filter($filter, function ($key) use ($allowed_columns) {
return in_array($key, $allowed_columns);
}, ARRAY_FILTER_USE_KEY);
}
if ((! is_null($filter)) && (count($filter)) > 0) {
$categories->ByFilter($filter);
} elseif ($request->filled('search')) {
$categories->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$categories->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
/*
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Transformers\CompaniesTransformer;
use App\Http\Transformers\SelectlistTransformer;
@@ -21,7 +22,7 @@ class CompaniesController extends Controller
*
* @since [v4.0]
*/
public function index(Request $request): JsonResponse|array
public function index(FilterRequest $request): JsonResponse|array
{
$this->authorize('view', Company::class);
@@ -49,8 +50,9 @@ class CompaniesController extends Controller
->with('adminuser')
->withCount('licenses as licenses_count', 'accessories as accessories_count', 'consumables as consumables_count', 'components as components_count', 'users as users_count');
if ($request->filled('search')) {
$companies->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$companies->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->filled('name')) {
@@ -73,10 +73,9 @@ class ComponentsController extends Controller
}
if ((! is_null($filter)) && (count($filter)) > 0) {
$components->ByFilter($filter);
} elseif ($request->filled('search')) {
$components->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$components->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->filled('name')) {
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api;
use App\Events\CheckoutableCheckedOut;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Requests\StoreConsumableRequest;
use App\Http\Transformers\ActionlogsTransformer;
@@ -25,7 +26,7 @@ class ConsumablesController extends Controller
*
* @since [v4.0]
*/
public function index(Request $request): array
public function index(FilterRequest $request): array
{
$this->authorize('index', Consumable::class);
@@ -60,21 +61,9 @@ class ConsumablesController extends Controller
'manufacturer',
];
$filter = [];
if ($request->filled('filter')) {
$filter = json_decode($request->input('filter'), true);
$filter = array_filter($filter, function ($key) use ($allowed_columns) {
return in_array($key, $allowed_columns);
}, ARRAY_FILTER_USE_KEY);
}
if ((! is_null($filter)) && (count($filter)) > 0) {
$consumables->ByFilter($filter);
} elseif ($request->filled('search')) {
$consumables->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$consumables->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->filled('name')) {
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Transformers\CustomFieldsetsTransformer;
use App\Http\Transformers\CustomFieldsTransformer;
use App\Models\CustomField;
@@ -35,10 +36,16 @@ class CustomFieldsetsController extends Controller
*
* @since [v1.8]
*/
public function index(): array
public function index(FilterRequest $request): array
{
$this->authorize('index', CustomField::class);
$fieldsets = CustomFieldset::withCount('fields as fields_count', 'models as models_count')->get();
$fieldsets = CustomFieldset::withCount('fields as fields_count', 'models as models_count');
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$fieldsets->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
$fieldsets->get();
return (new CustomFieldsetsTransformer)->transformCustomFieldsets($fieldsets, $fieldsets->count());
}
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Requests\StoreDepartmentRequest;
use App\Http\Transformers\DepartmentsTransformer;
@@ -22,7 +23,7 @@ class DepartmentsController extends Controller
*
* @since [v4.0]
*/
public function index(Request $request): JsonResponse|array
public function index(FilterRequest $request): JsonResponse|array
{
$this->authorize('view', Department::class);
$allowed_columns = ['id', 'name', 'image', 'users_count', 'notes', 'tag_color'];
@@ -43,8 +44,9 @@ class DepartmentsController extends Controller
'departments.notes',
])->with('location')->with('manager')->with('company')->withCount('users as users_count');
if ($request->filled('search')) {
$departments = $departments->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$departments->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->filled('name')) {
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Transformers\DepreciationsTransformer;
use App\Models\Depreciation;
use Illuminate\Http\JsonResponse;
@@ -18,7 +19,7 @@ class DepreciationsController extends Controller
*
* @since [v4.0]
*/
public function index(Request $request): JsonResponse|array
public function index(FilterRequest $request): JsonResponse|array
{
$this->authorize('view', Depreciation::class);
$allowed_columns = [
@@ -33,14 +34,15 @@ class DepreciationsController extends Controller
'licenses_count',
];
$depreciations = Depreciation::select('id', 'name', 'months', 'depreciation_min', 'depreciation_type', 'created_at', 'updated_at', 'created_by')
$depreciations = Depreciation::select(['id', 'name', 'months', 'depreciation_min', 'depreciation_type', 'created_at', 'updated_at', 'created_by'])
->with('adminuser')
->withCount('assets as assets_count')
->withCount('models as models_count')
->withCount('licenses as licenses_count');
if ($request->filled('search')) {
$depreciations = $depreciations->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$depreciations->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
// Make sure the offset and limit are actually integers and do not exceed system limits
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Transformers\GroupsTransformer;
use App\Models\Group;
use Illuminate\Http\JsonResponse;
@@ -18,7 +19,7 @@ class GroupsController extends Controller
*
* @since [v4.0]
*/
public function index(Request $request): JsonResponse|array
public function index(FilterRequest $request): JsonResponse|array
{
$this->authorize('superadmin');
@@ -26,8 +27,9 @@ class GroupsController extends Controller
$groups = Group::select(['id', 'name', 'permissions', 'notes', 'created_at', 'updated_at', 'created_by'])->with('adminuser')->withCount('users as users_count');
if ($request->filled('search')) {
$groups = $groups->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$groups->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->filled('name')) {
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Transformers\ActionlogsTransformer;
use App\Http\Transformers\LicensesTransformer;
use App\Http\Transformers\SelectlistTransformer;
@@ -22,7 +23,7 @@ class LicensesController extends Controller
*
* @since [v4.0]
*/
public function index(Request $request): JsonResponse|array
public function index(FilterRequest $request): JsonResponse|array
{
$this->authorize('view', License::class);
@@ -97,8 +98,9 @@ class LicensesController extends Controller
$licenses->whereNull('expiration_date');
}
if ($request->filled('search')) {
$licenses = $licenses->TextSearch($request->input('search'));
// This invokes the Searchable model trait and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$licenses->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->input('deleted') == 'true') {
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Transformers\ActionlogsTransformer;
use App\Http\Transformers\AssetsTransformer;
@@ -32,7 +33,7 @@ class LocationsController extends Controller
*
* @return Response
*/
public function index(Request $request): JsonResponse|array
public function index(FilterRequest $request): JsonResponse|array
{
$this->authorize('view', Location::class);
$allowed_columns = [
@@ -107,8 +108,9 @@ class LocationsController extends Controller
$locations = Company::scopeCompanyables($locations);
}
if ($request->filled('search')) {
$locations = $locations->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$locations->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->filled('name')) {
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Transformers\ActionlogsTransformer;
use App\Http\Transformers\MaintenancesTransformer;
@@ -32,15 +33,16 @@ class MaintenancesController extends Controller
*
* @since [v1.8]
*/
public function index(Request $request): JsonResponse|array
public function index(FilterRequest $request): JsonResponse|array
{
$this->authorize('view', Asset::class);
$maintenances = Maintenance::select('maintenances.*')
->with('asset', 'asset.model', 'asset.location', 'asset.defaultLoc', 'supplier', 'asset.company', 'asset.assetstatus', 'adminuser', 'asset.assignedTo');
if ($request->filled('search')) {
$maintenances = $maintenances->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$maintenances->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->filled('asset_id')) {
@@ -6,6 +6,7 @@ use App\Actions\Manufacturers\DeleteManufacturerAction;
use App\Exceptions\ItemStillHasChildren;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Transformers\ManufacturersTransformer;
use App\Http\Transformers\SelectlistTransformer;
@@ -28,7 +29,7 @@ class ManufacturersController extends Controller
*
* @return Response
*/
public function index(Request $request): JsonResponse|array
public function index(FilterRequest $request): JsonResponse|array
{
$this->authorize('view', Manufacturer::class);
$allowed_columns = [
@@ -81,8 +82,9 @@ class ManufacturersController extends Controller
$manufacturers->onlyTrashed();
}
if ($request->filled('search')) {
$manufacturers = $manufacturers->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$manufacturers->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->filled('name')) {
@@ -3,10 +3,10 @@
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Transformers\ActionlogsTransformer;
use App\Models\Actionlog;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ReportsController extends Controller
{
@@ -17,14 +17,15 @@ class ReportsController extends Controller
*
* @since [v4.0]
*/
public function index(Request $request): JsonResponse|array
public function index(FilterRequest $request): JsonResponse|array
{
$this->authorize('activity.view');
$actionlogs = Actionlog::with('item', 'user', 'adminuser', 'target', 'location');
if ($request->filled('search')) {
$actionlogs = $actionlogs->TextSearch(e($request->input('search')));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$actionlogs->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if (($request->filled('target_type')) && ($request->filled('target_id'))) {
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Transformers\AssetsTransformer;
use App\Http\Transformers\PieChartTransformer;
use App\Http\Transformers\SelectlistTransformer;
@@ -23,7 +24,7 @@ class StatuslabelsController extends Controller
*
* @since [v4.0]
*/
public function index(Request $request): array
public function index(FilterRequest $request): array
{
$this->authorize('view', Statuslabel::class);
$allowed_columns = [
@@ -38,8 +39,9 @@ class StatuslabelsController extends Controller
$statuslabels = Statuslabel::with('adminuser')->withCount('assets as assets_count');
if ($request->filled('search')) {
$statuslabels = $statuslabels->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$statuslabels->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->filled('name')) {
@@ -11,6 +11,7 @@ use App\Exceptions\ItemStillHasLicenses;
use App\Exceptions\ItemStillHasMaintenances;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Transformers\SelectlistTransformer;
use App\Http\Transformers\SuppliersTransformer;
@@ -31,7 +32,7 @@ class SuppliersController extends Controller
*
* @return Response
*/
public function index(Request $request): array
public function index(FilterRequest $request): array
{
$this->authorize('view', Supplier::class);
$allowed_columns = [
@@ -67,8 +68,9 @@ class SuppliersController extends Controller
->withCount('consumables as consumables_count')
->with('adminuser');
if ($request->filled('search')) {
$suppliers->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$suppliers->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->filled('name')) {
+3 -4
View File
@@ -171,10 +171,9 @@ class UsersController extends Controller
}
if ((! is_null($filter)) && (count($filter)) > 0) {
$users->ByFilter($filter);
} elseif ($request->filled('search')) {
$users->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$users->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->filled('activated')) {
+14 -95
View File
@@ -2,7 +2,6 @@
namespace App\Models;
use App\Http\Controllers\Api\AccessoriesController\checkedout;
use App\Models\Traits\Acceptable;
use App\Models\Traits\CompanyableTrait;
use App\Models\Traits\HasUploads;
@@ -47,7 +46,15 @@ class Accessory extends SnipeModel
*
* @var array
*/
protected $searchableAttributes = ['name', 'model_number', 'order_number', 'purchase_date', 'notes'];
protected $searchableAttributes = [
'created_at',
'model_number',
'name',
'notes',
'order_number',
'purchase_cost',
'purchase_date',
];
/**
* The relations and their attributes that should be included when searching the model.
@@ -57,9 +64,13 @@ class Accessory extends SnipeModel
protected $searchableRelations = [
'category' => ['name'],
'company' => ['name'],
'location' => ['name'],
'manufacturer' => ['name'],
'supplier' => ['name'],
'location' => ['name'],
];
protected $searchableCounts = [
'checkouts_count',
];
/**
@@ -295,20 +306,6 @@ class Accessory extends SnipeModel
->with('assignedTo');
}
/**
* Establishes the accessory -> admin user relationship
*
* @author A. Gianotto <snipe@snipe.net>
*
* @since [v7.0.13]
*
* @return Relation
*/
public function adminuser()
{
return $this->belongsTo(User::class, 'created_by')->withTrashed();
}
/**
* Checks whether or not the accessory has users
*
@@ -456,84 +453,6 @@ class Accessory extends SnipeModel
* BEGIN QUERY SCOPES
* -----------------------------------------------
**/
/**
* Query builder scope to search on text filters for complex Bootstrap Tables API
*
* @param Builder $query Query builder instance
* @param text $filter JSON array of search keys and terms
* @return Builder Modified query builder
*/
public function scopeByFilter($query, $filter)
{
return $query->where(
function ($query) use ($filter) {
foreach ($filter as $fieldname => $search_val) {
if ($fieldname == 'name') {
$query->where('accessories.name', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'notes') {
$query->where('accessories.notes', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'model_number') {
$query->where('accessories.model_number', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'order_number') {
$query->where('accessories.order_number', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'purchase_cost') {
$query->where('accessories.purchase_cost', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'location') {
$query->whereHas(
'location', function ($query) use ($search_val) {
$query->where('locations.name', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'manufacturer') {
$query->whereHas(
'manufacturer', function ($query) use ($search_val) {
$query->where('manufacturers.name', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'supplier') {
$query->whereHas(
'supplier', function ($query) use ($search_val) {
$query->where('suppliers.name', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'category') {
$query->whereHas(
'category', function ($query) use ($search_val) {
$query->where('categories.name', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'company') {
$query->whereHas(
'company', function ($query) use ($search_val) {
$query->where('companies.name', 'LIKE', '%'.$search_val.'%');
}
);
}
}
}
);
}
/**
* Query builder scope to order on created_by name
+8 -244
View File
@@ -37,8 +37,14 @@ class Asset extends Depreciable
protected $with = ['model', 'adminuser'];
use CompanyableTrait;
use HasFactory, Loggable, Presentable, Requestable, SoftDeletes, UniqueUndeletedTrait, ValidatingTrait;
use HasFactory;
use HasUploads;
use Loggable;
use Presentable;
use Requestable;
use SoftDeletes;
use UniqueUndeletedTrait;
use ValidatingTrait;
public const LOCATION = 'location';
@@ -953,20 +959,6 @@ class Asset extends Depreciable
->orderBy('created_at', 'desc');
}
/**
* Get user who created the item
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @since [v1.0]
*
* @return Relation
*/
public function adminuser()
{
return $this->belongsTo(User::class, 'created_by')->withTrashed();
}
/**
* Establishes the asset -> status relationship
*
@@ -1326,6 +1318,7 @@ class Asset extends Depreciable
/**
* Run additional, advanced searches.
* This overrides the advancedTextSearch method on the Searchable model trait to add searching of assigned user, location, and assets.
*
* @param array $terms The search terms
* @return Builder
@@ -1890,235 +1883,6 @@ class Asset extends Depreciable
)->withTrashed()->whereNull('assets.deleted_at'); // workaround for laravel bug
}
/**
* Query builder scope to search on text filters for complex Bootstrap Tables API
*
* @param \Illuminate\Database\Query\Builder $query Query builder instance
* @param text $filter JSON array of search keys and terms
* @return \Illuminate\Database\Query\Builder Modified query builder
*/
public function scopeByFilter($query, $filter)
{
return $query->where(
function ($query) use ($filter) {
foreach ($filter as $key => $search_val) {
$fieldname = str_replace('custom_fields.', '', $key);
if ($fieldname == 'asset_tag') {
$query->where('assets.asset_tag', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'name') {
$query->where('assets.name', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'serial') {
$query->where('assets.serial', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'purchase_date') {
$query->where('assets.purchase_date', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'purchase_cost') {
$query->where('assets.purchase_cost', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'notes') {
$query->where('assets.notes', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'order_number') {
$query->where('assets.order_number', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'status_label') {
$query->whereHas(
'assetstatus', function ($query) use ($search_val) {
$query->where('status_labels.name', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'location') {
$query->whereHas(
'location', function ($query) use ($search_val) {
$query->where('locations.name', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'rtd_location') {
$query->whereHas(
'defaultLoc', function ($query) use ($search_val) {
$query->where('locations.name', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'assigned_to') {
$query->whereHasMorph(
'assignedTo', [User::class], function ($query) use ($search_val) {
$query->where(
function ($query) use ($search_val) {
$query->where('users.first_name', 'LIKE', '%'.$search_val.'%')
->orWhere('users.last_name', 'LIKE', '%'.$search_val.'%')
->orWhere('users.display_name', 'LIKE', '%'.$search_val.'%')
->orWhere('users.username', 'LIKE', '%'.$search_val.'%');
}
);
}
)->orWhereHasMorph(
'assignedTo', [Location::class], function ($query) use ($search_val) {
$query->where('locations.name', 'LIKE', '%'.$search_val.'%');
}
)->orWhereHasMorph(
'assignedTo', [Asset::class], function ($query) use ($search_val) {
$query->where(
function ($query) use ($search_val) {
// Don't use the asset table prefix here because it will pull from the original asset,
// not the subselect we're doing here to get the assigned asset
$query->where('name', 'LIKE', '%'.$search_val.'%')
->orWhere('asset_tag', 'LIKE', '%'.$search_val.'%');
}
);
}
);
}
if ($fieldname == 'manufacturer') {
$query->whereHas(
'model', function ($query) use ($search_val) {
$query->whereHas(
'manufacturer', function ($query) use ($search_val) {
$query->where(
function ($query) use ($search_val) {
$query->where('manufacturers.name', 'LIKE', '%'.$search_val.'%');
}
);
}
);
}
);
}
if ($fieldname == 'category') {
$query->whereHas(
'model', function ($query) use ($search_val) {
$query->whereHas(
'category', function ($query) use ($search_val) {
$query->where(
function ($query) use ($search_val) {
$query->where('categories.name', 'LIKE', '%'.$search_val.'%')
->orWhere('models.name', 'LIKE', '%'.$search_val.'%')
->orWhere('models.model_number', 'LIKE', '%'.$search_val.'%');
}
);
}
);
}
);
}
if ($fieldname == 'model') {
$query->whereHas(
'model', function ($query) use ($search_val) {
$query->where('models.name', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'model_number') {
$query->whereHas(
'model', function ($query) use ($search_val) {
$query->where('models.model_number', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'company') {
$query->whereHas(
'company', function ($query) use ($search_val) {
$query->where('companies.name', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'supplier') {
$query->whereHas(
'supplier', function ($query) use ($search_val) {
$query->where('suppliers.name', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'status_label') {
$query->whereHas(
'assetstatus', function ($query) use ($search_val) {
$query->where('status_labels.name', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'jobtitle') {
$query->where(function ($query) use ($search_val) {
if (is_array($search_val)) {
$query->whereHasMorph(
'assignedTo',
[User::class],
function ($query) use ($search_val) {
$query->whereIn('users.jobtitle', $search_val);
}
);
} else {
$query->whereHasMorph(
'assignedTo',
[User::class],
function ($query) use ($search_val) {
$query->where(function ($query) use ($search_val) {
$query->where('users.jobtitle', 'LIKE', '%'.$search_val.'%');
});
}
);
}
});
}
/**
* THIS CLUNKY BIT IS VERY IMPORTANT
*
* Although inelegant, this section matters a lot when querying against fields that do not
* exist on the asset table. There's probably a better way to do this moving forward, for
* example using the Schema:: methods to determine whether or not a column actually exists,
* or even just using the $searchableRelations variable earlier in this file.
*
* In short, this set of statements tells the query builder to ONLY query against an
* actual field that's being passed if it doesn't meet known relational fields. This
* allows us to query custom fields directly in the assets table
* (regardless of their name) and *skip* any fields that we already know can only be
* searched through relational searches that we do earlier in this method.
*
* For example, we do not store "location" as a field on the assets table, we store
* that relationship through location_id on the assets table, therefore querying
* assets.location would fail, as that field doesn't exist -- plus we're already searching
* against those relationships earlier in this method.
*
* - snipe
*/
if (($fieldname != 'category') && ($fieldname != 'model_number') && ($fieldname != 'rtd_location') && ($fieldname != 'location') && ($fieldname != 'supplier')
&& ($fieldname != 'status_label') && ($fieldname != 'assigned_to') && ($fieldname != 'model') && ($fieldname != 'jobtitle') && ($fieldname != 'company') && ($fieldname != 'manufacturer')
) {
$query->where('assets.'.$fieldname, 'LIKE', '%'.$search_val.'%');
}
}
}
);
}
/**
* Query builder scope to order on model
*
+20 -64
View File
@@ -85,10 +85,12 @@ class AssetModel extends SnipeModel
* @var array
*/
protected $searchableAttributes = [
'name',
'model_number',
'notes',
'created_at',
'eol',
'min_amt',
'model_number',
'name',
'notes',
];
/**
@@ -100,6 +102,20 @@ class AssetModel extends SnipeModel
'depreciation' => ['name'],
'category' => ['name'],
'manufacturer' => ['name'],
'fieldset' => ['name'],
'adminuser' => ['first_name', 'last_name', 'display_name'],
];
/**
* Computed aliases (withCount/withSum) that can be searched via TextSearch filters.
*
* @var array
*/
protected $searchableCounts = [
'assets_count',
'remaining',
'assets_assigned_count',
'assets_archived_count',
];
protected static function booted(): void
@@ -147,6 +163,7 @@ class AssetModel extends SnipeModel
if ($this->availableAssets()->count() == 0) {
return 0;
}
return $this->availableAssets()->count() / $this->assets()->count() * 100;
}
@@ -261,73 +278,12 @@ class AssetModel extends SnipeModel
&& ($this->deleted_at == '');
}
/**
* Get user who created the item
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @since [v1.0]
*
* @return Relation
*/
public function adminuser()
{
return $this->belongsTo(User::class, 'created_by')->withTrashed();
}
/**
* -----------------------------------------------
* BEGIN QUERY SCOPES
* -----------------------------------------------
**/
/**
* Query builder scope to search on text filters for complex Bootstrap Tables API
*
* @param Builder $query Query builder instance
* @param text $filter JSON array of search keys and terms
* @return Builder Modified query builder
*/
public function scopeByFilter($query, $filter)
{
return $query->where(
function ($query) use ($filter) {
foreach ($filter as $fieldname => $search_val) {
if ($fieldname == 'name') {
$query->where('models.name', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'notes') {
$query->where('models.notes', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'model_number') {
$query->where('models.model_number', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'category') {
$query->whereHas(
'category', function ($query) use ($search_val) {
$query->where('categories.name', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'manufacturer') {
$query->whereHas(
'manufacturer', function ($query) use ($search_val) {
$query->where('manufacturers.name', 'LIKE', '%'.$search_val.'%');
}
);
}
}
}
);
}
/**
* scopeInCategory
* Get all models that are in the array of category ids
+18 -34
View File
@@ -89,14 +89,30 @@ class Category extends SnipeModel
*
* @var array
*/
protected $searchableAttributes = ['name', 'category_type', 'notes'];
protected $searchableAttributes = [
'name',
'category_type',
'notes',
'eula_text',
'created_at',
];
/**
* The relations and their attributes that should be included when searching the model.
*
* @var array
*/
protected $searchableRelations = [];
protected $searchableRelations = [
'adminuser' => ['first_name', 'last_name', 'display_name'],
];
protected $searchableCounts = [
'accessories_count',
'consumables_count',
'components_count',
'licenses_count',
'models_count',
];
/**
* Checks if category can be deleted
@@ -263,11 +279,6 @@ class Category extends SnipeModel
return $this->hasMany(AssetModel::class, 'category_id');
}
public function adminuser()
{
return $this->belongsTo(User::class, 'created_by')->withTrashed();
}
/**
* Checks for a category-specific EULA, and if that doesn't exist,
* checks for a settings level EULA
@@ -315,33 +326,6 @@ class Category extends SnipeModel
* -----------------------------------------------
**/
/**
* Query builder scope to search on text filters for complex Bootstrap Tables API
*
* @param Builder $query Query builder instance
* @param text $filter JSON array of search keys and terms
* @return Builder Modified query builder
*/
public function scopeByFilter($query, $filter)
{
return $query->where(
function ($query) use ($filter) {
foreach ($filter as $fieldname => $search_val) {
if ($fieldname == 'name') {
$query->where('categories.name', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'category_type') {
$query->where('categories.category_type', 'LIKE', '%'.$search_val.'%');
}
}
}
);
}
/**
* Query builder scope for whether or not the category requires acceptance
*
+12 -7
View File
@@ -60,14 +60,24 @@ final class Company extends SnipeModel
*
* @var array
*/
protected $searchableAttributes = ['name', 'phone', 'fax', 'email', 'created_at', 'updated_at'];
protected $searchableAttributes = [
'name',
'phone',
'fax',
'email',
'created_at',
'updated_at',
'notes',
];
/**
* The relations and their attributes that should be included when searching the model.
*
* @var array
*/
protected $searchableRelations = [];
protected $searchableRelations = [
'adminuser' => ['first_name', 'last_name', 'display_name'],
];
/**
* The attributes that are mass assignable.
@@ -317,11 +327,6 @@ final class Company extends SnipeModel
}
public function adminuser()
{
return $this->belongsTo(User::class, 'created_by')->withTrashed();
}
/**
* I legit do not know what this method does, but we can't remove it (yet).
*
+1 -103
View File
@@ -115,6 +115,7 @@ class Component extends SnipeModel
'location' => ['name'],
'supplier' => ['name'],
'manufacturer' => ['name'],
'adminuser' => ['first_name', 'last_name', 'display_name'],
];
public static function booted()
@@ -164,22 +165,6 @@ class Component extends SnipeModel
return $this->belongsToMany(Asset::class, 'components_assets')->withPivot('id', 'assigned_qty', 'created_at', 'created_by', 'note');
}
/**
* Establishes the component -> admin user relationship
*
* @todo this is probably not needed - refactor
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @since [v3.0]
*
* @return Relation
*/
public function adminuser()
{
return $this->belongsTo(User::class, 'created_by')->withTrashed();
}
/**
* Establishes the component -> company relationship
*
@@ -410,93 +395,6 @@ class Component extends SnipeModel
* -----------------------------------------------
**/
/**
* Query builder scope to search on text filters for complex Bootstrap Tables API
*
* @param Builder $query Query builder instance
* @param text $filter JSON array of search keys and terms
* @return Builder Modified query builder
*/
public function scopeByFilter($query, $filter)
{
return $query->where(
function ($query) use ($filter) {
foreach ($filter as $fieldname => $search_val) {
if ($fieldname == 'name') {
$query->where('components.name', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'notes') {
$query->where('components.notes', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'model_number') {
$query->where('components.model_number', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'order_number') {
$query->where('components.order_number', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'serial') {
$query->where('components.serial', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'serial') {
$query->where('components.serial', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'purchase_cost') {
$query->where('components.purchase_cost', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'location') {
$query->whereHas(
'location', function ($query) use ($search_val) {
$query->where('locations.name', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'manufacturer') {
$query->whereHas(
'manufacturer', function ($query) use ($search_val) {
$query->where('manufacturers.name', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'supplier') {
$query->whereHas(
'supplier', function ($query) use ($search_val) {
$query->where('suppliers.name', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'category') {
$query->whereHas(
'category', function ($query) use ($search_val) {
$query->where('categories.name', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'company') {
$query->whereHas(
'company', function ($query) use ($search_val) {
$query->where('companies.name', 'LIKE', '%'.$search_val.'%');
}
);
}
}
}
);
}
/**
* Query builder scope to order on company
*
+11 -95
View File
@@ -96,7 +96,15 @@ class Consumable extends SnipeModel
*
* @var array
*/
protected $searchableAttributes = ['name', 'order_number', 'purchase_cost', 'purchase_date', 'item_no', 'model_number', 'notes'];
protected $searchableAttributes = [
'name',
'order_number',
'purchase_cost',
'purchase_date',
'item_no',
'model_number',
'notes',
];
/**
* The relations and their attributes that should be included when searching the model.
@@ -109,6 +117,7 @@ class Consumable extends SnipeModel
'location' => ['name'],
'manufacturer' => ['name'],
'supplier' => ['name'],
'adminuser' => ['first_name', 'last_name', 'display_name'],
];
/**
@@ -141,20 +150,6 @@ class Consumable extends SnipeModel
&& ($this->deleted_at == '');
}
/**
* Establishes the consumable -> admin user relationship
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @since [v3.0]
*
* @return Relation
*/
public function adminuser()
{
return $this->belongsTo(User::class, 'created_by')->withTrashed();
}
/**
* Establishes the component -> assignments relationship
*
@@ -174,6 +169,7 @@ class Consumable extends SnipeModel
if ($this->consumables_users_count == 0) {
return 100;
}
return ($this->qty - $this->consumables_users_count) / $this->qty * 100;
}
@@ -186,7 +182,6 @@ class Consumable extends SnipeModel
*
* @return Relation
*/
public function company()
{
return $this->belongsTo(Company::class, 'company_id');
@@ -417,85 +412,6 @@ class Consumable extends SnipeModel
* @param text $filter JSON array of search keys and terms
* @return Builder Modified query builder
*/
public function scopeByFilter($query, $filter)
{
return $query->where(
function ($query) use ($filter) {
foreach ($filter as $fieldname => $search_val) {
if ($fieldname == 'name') {
$query->where('consumables.name', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'notes') {
$query->where('consumables.notes', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'model_number') {
$query->where('consumables.model_number', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'order_number') {
$query->where('consumables.order_number', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'item_no') {
$query->where('consumables.item_no', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'serial') {
$query->where('consumables.serial', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'purchase_cost') {
$query->where('consumables.purchase_cost', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'location') {
$query->whereHas(
'location', function ($query) use ($search_val) {
$query->where('locations.name', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'manufacturer') {
$query->whereHas(
'manufacturer', function ($query) use ($search_val) {
$query->where('manufacturers.name', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'supplier') {
$query->whereHas(
'supplier', function ($query) use ($search_val) {
$query->where('suppliers.name', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'category') {
$query->whereHas(
'category', function ($query) use ($search_val) {
$query->where('categories.name', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'company') {
$query->whereHas(
'company', function ($query) use ($search_val) {
$query->where('companies.name', 'LIKE', '%'.$search_val.'%');
}
);
}
}
}
);
}
/**
* Query builder scope to order on company
+24
View File
@@ -88,6 +88,30 @@ class CustomField extends Model
'show_in_requestable_list',
];
/**
* The attributes that should be included when searching the model.
*
* @var array
*/
protected $searchableAttributes = [
'name',
'format',
'element',
'db_column',
'help_text',
];
/**
* The relations and their attributes that should be included when searching the model.
*
* @var array
*/
protected $searchableRelations = [
'fieldset' => ['name'],
'assetModels' => ['name'],
'adminuser' => ['first_name', 'last_name', 'display_name'],
];
/**
* This is confusing, since it's actually the custom fields table that
* we're usually modifying, but since we alter the assets table, we have to
+9 -2
View File
@@ -77,14 +77,21 @@ class Department extends SnipeModel
*
* @var array
*/
protected $searchableAttributes = ['name', 'notes', 'phone', 'fax'];
protected $searchableAttributes = [
'name',
'notes',
'phone',
'fax',
];
/**
* The relations and their attributes that should be included when searching the model.
*
* @var array
*/
protected $searchableRelations = [];
protected $searchableRelations = [
'adminuser' => ['first_name', 'last_name', 'display_name'],
];
public function isDeletable()
{
+11 -3
View File
@@ -40,7 +40,10 @@ class Depreciation extends SnipeModel
*
* @var array
*/
protected $fillable = ['name', 'months'];
protected $fillable = [
'name',
'months',
];
use Searchable;
@@ -49,14 +52,19 @@ class Depreciation extends SnipeModel
*
* @var array
*/
protected $searchableAttributes = ['name', 'months'];
protected $searchableAttributes = [
'name',
'months',
];
/**
* The relations and their attributes that should be included when searching the model.
*
* @var array
*/
protected $searchableRelations = [];
protected $searchableRelations = [
'adminuser' => ['first_name', 'last_name', 'display_name'],
];
public function isDeletable()
{
+9 -17
View File
@@ -35,6 +35,7 @@ class Group extends SnipeModel
* @var bool
*/
protected $injectUniqueIdentifier = true;
protected $presenter = GroupPresenter::class;
use Searchable;
@@ -45,15 +46,20 @@ class Group extends SnipeModel
*
* @var array
*/
protected $searchableAttributes = ['name', 'created_at', 'notes'];
protected $searchableAttributes = [
'name',
'created_at',
'notes',
];
/**
* The relations and their attributes that should be included when searching the model.
*
* @var array
*/
protected $searchableRelations = [];
protected $searchableRelations = [
'adminuser' => ['first_name', 'last_name', 'display_name'],
];
public function isDeletable()
{
@@ -75,20 +81,6 @@ class Group extends SnipeModel
return $this->belongsToMany(User::class, 'users_groups');
}
/**
* Get the user that created the group
*
* @author A. Gianotto <snipe@snipe.net>
*
* @since [v6.3.0]
*
* @return Relation
*/
public function adminuser()
{
return $this->belongsTo(User::class, 'created_by')->withTrashed();
}
/**
* Decode JSON permissions into array
*
+3 -14
View File
@@ -112,6 +112,8 @@ class License extends Depreciable
'purchase_cost',
'purchase_date',
'expiration_date',
'license_email',
'license_name',
];
/**
@@ -125,6 +127,7 @@ class License extends Depreciable
'category' => ['name'],
'depreciation' => ['name'],
'supplier' => ['name'],
'adminuser' => ['first_name', 'last_name', 'display_name'],
];
protected $appends = ['free_seat_count'];
@@ -520,20 +523,6 @@ class License extends Depreciable
->orderBy('created_at', 'desc');
}
/**
* Establishes the license -> admin user relationship
*
* @author A. Gianotto <snipe@snipe.net>
*
* @since [v2.0]
*
* @return Relation
*/
public function adminuser()
{
return $this->belongsTo(User::class, 'created_by')->withTrashed();
}
/**
* Returns the total number of all license seats
*
+1 -12
View File
@@ -115,6 +115,7 @@ class Location extends SnipeModel
protected $searchableRelations = [
'parent' => ['name'],
'company' => ['name'],
'adminuser' => ['first_name', 'last_name', 'display_name'],
];
/**
@@ -158,18 +159,6 @@ class Location extends SnipeModel
return $this->hasMany(User::class, 'location_id');
}
/**
* Establishes the location -> admin user relationship
*
* @author A. Gianotto <snipe@snipe.net>
*
* @return Relation
*/
public function adminuser()
{
return $this->belongsTo(User::class, 'created_by')->withTrashed();
}
/**
* Find assets with this location as their location_id
*
+1 -15
View File
@@ -94,6 +94,7 @@ class Maintenance extends SnipeModel implements ICompanyableChild
'asset.supplier' => ['name'],
'asset.assetstatus' => ['name'],
'supplier' => ['name'],
'adminuser' => ['first_name', 'last_name', 'display_name'],
];
public function getCompanyableParents()
@@ -195,21 +196,6 @@ class Maintenance extends SnipeModel implements ICompanyableChild
->withTrashed();
}
/**
* Get the admin who created the maintenance
*
* @return mixed
*
* @author A. Gianotto <snipe@snipe.net>
*
* @version v3.0
*/
public function adminuser()
{
return $this->belongsTo(User::class, 'created_by')
->withTrashed();
}
public function supplier()
{
return $this->belongsTo(Supplier::class, 'supplier_id')
+8 -7
View File
@@ -67,14 +67,20 @@ class Manufacturer extends SnipeModel
*
* @var array
*/
protected $searchableAttributes = ['name', 'created_at', 'notes'];
protected $searchableAttributes = [
'name',
'created_at',
'notes',
];
/**
* The relations and their attributes that should be included when searching the model.
*
* @var array
*/
protected $searchableRelations = [];
protected $searchableRelations = [
'adminuser' => ['first_name', 'last_name', 'display_name'],
];
public function isDeletable()
{
@@ -117,11 +123,6 @@ class Manufacturer extends SnipeModel
return $this->hasMany(Component::class, 'manufacturer_id');
}
public function adminuser()
{
return $this->belongsTo(User::class, 'created_by')->withTrashed();
}
/**
* Query builder scope to order on the user that created it
*/
+3 -6
View File
@@ -143,12 +143,9 @@ class PredefinedKit extends SnipeModel
*
* @var array
*/
protected $searchableRelations = [];
public function adminuser()
{
return $this->belongsTo(User::class, 'created_by');
}
protected $searchableRelations = [
'adminuser' => ['first_name', 'last_name', 'display_name'],
];
/**
* Establishes the kits -> models relationship
+15
View File
@@ -7,6 +7,7 @@ use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Facades\Storage;
class SnipeModel extends Model
@@ -240,6 +241,20 @@ class SnipeModel extends Model
return $this->hasMany(Actionlog::class, 'target_id')->where('target_type', '=', self::class)->orderBy('created_at', 'DESC')->withTrashed();
}
/**
* Establishes the object -> admin user relationship
*
* @return Relation
*
* @since [v3.0]
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*/
public function adminuser()
{
return $this->belongsTo(User::class, 'created_by')->withTrashed();
}
public function showCheckoutButton($item)
{
+194 -10
View File
@@ -11,6 +11,25 @@ use Illuminate\Support\Facades\DB;
* This trait allows for cleaner searching of models,
* moving from complex queries to an easier declarative syntax.
*
* This handles all the out of the box advanced search stuff (using the "advanced search" bootstrap table plugin),
* allowing you to just define which attributes and relations should be searched, and then it does the rest.
*
* You can override these trait methods (for example, advancedSearch) if you need different ebhavior, but this really
* should cover most of the use cases, and allows you to easily add searching to your models without having to
* write complex queries.
*
* To use this:
*
* 1. Make sure the model has $searchableAttributes and $searchableRelations set
* 2. Make sure you import the App\Models\Traits\Searchable trait and use Searchable in the model
* 3. Make sure you check the request for the request input filter or search and then invoke the TextSearch scope, like:
*
* if ($request->filled('filter') || $request->filled('search')) {
* $whateverModel->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
* }
* 4. Set the "data-advanced
*
*
* @author Till Deeke <kontakt@tilldeeke.de>
*/
trait Searchable
@@ -24,7 +43,13 @@ trait Searchable
*/
public function scopeTextSearch($query, $search)
{
$terms = $this->prepeareSearchTerms($search);
$preparedSearch = $this->prepareSearchInput((string) $search);
$terms = $preparedSearch['terms'];
$filters = $preparedSearch['filters'];
if (! empty($filters)) {
return $this->applySearchFilters($query, $filters);
}
/**
* Search the attributes of this model
@@ -49,6 +74,78 @@ trait Searchable
return $query;
}
/**
* Parse free-text terms and structured filters for TextSearch.
*
* Supported filter inputs:
* - {"field":"value"}
* - filter:{"field":"value"}
*/
private function prepareSearchInput(string $search): array
{
$search = trim($search);
$parsedFilters = $this->parseStructuredFilterPayload($search);
if ($parsedFilters !== null) {
return [
'terms' => [],
'filters' => $parsedFilters,
];
}
return [
'terms' => $this->prepeareSearchTerms($search),
'filters' => [],
];
}
/**
* Normalize a structured filter payload into scalar string filters.
*/
private function parseStructuredFilterPayload(string $search): ?array
{
if ($search === '') {
return null;
}
$payload = $search;
if (str_starts_with($search, 'filter:')) {
$payload = substr($search, 7);
} elseif (! (str_starts_with($search, '{') && str_ends_with($search, '}'))) {
return null;
}
$decoded = json_decode($payload, true);
if (! is_array($decoded)) {
return null;
}
$filters = [];
foreach ($decoded as $key => $value) {
if (! is_string($key)) {
continue;
}
if (! is_scalar($value) && $value !== null) {
continue;
}
$normalizedValue = trim((string) ($value ?? ''));
if ($normalizedValue === '') {
continue;
}
$filters[$key] = $normalizedValue;
}
return $filters;
}
/**
* Prepares the search term, splitting and cleaning it up
*
@@ -60,11 +157,88 @@ trait Searchable
return explode(' OR ', $search);
}
/**
* Apply structured filters to searchable attributes and relations.
*
* @param array<string, string> $filters
*/
private function applySearchFilters(Builder $query, array $filters): Builder
{
$searchableAttributes = $this->getSearchableAttributes();
$searchableCounts = $this->getSearchableCounts();
$searchableRelations = $this->getSearchableRelations();
$table = $this->getTable();
foreach ($filters as $filterKey => $filterValue) {
if (in_array($filterKey, $searchableAttributes, true)) {
$query->where($table.'.'.$filterKey, 'LIKE', '%'.$filterValue.'%');
continue;
}
if (in_array($filterKey, $searchableCounts, true)) {
$query = $this->applyCountAliasFilter($query, $filterKey, $filterValue);
continue;
}
if (! array_key_exists($filterKey, $searchableRelations)) {
continue;
}
$relationColumns = (array) $searchableRelations[$filterKey];
$query->whereHas($filterKey, function (Builder $relationQuery) use ($filterKey, $relationColumns, $filterValue) {
$relationTable = $this->getRelationTable($filterKey);
$firstConditionAdded = false;
foreach ($relationColumns as $relationColumn) {
if (! $firstConditionAdded) {
$relationQuery->where($relationTable.'.'.$relationColumn, 'LIKE', '%'.$filterValue.'%');
$firstConditionAdded = true;
continue;
}
$relationQuery->orWhere($relationTable.'.'.$relationColumn, 'LIKE', '%'.$filterValue.'%');
}
if (($filterKey === 'adminuser') || ($filterKey === 'user')) {
$relationQuery->orWhereRaw(
$this->buildMultipleColumnSearch(
[
'users.first_name',
'users.last_name',
'users.display_name',
]
),
["%{$filterValue}%"]
);
}
});
}
return $query;
}
/**
* Apply filtering on computed count aliases (for example withCount aliases).
*/
private function applyCountAliasFilter(Builder $query, string $countAlias, string $filterValue): Builder
{
if (is_numeric($filterValue)) {
return $query->having($countAlias, '=', (int) $filterValue);
}
return $query->having($countAlias, 'LIKE', '%'.$filterValue.'%');
}
/**
* Searches the models attributes for the search terms
*
* @param Illuminate\Database\Eloquent\Builder $query
* @return Illuminate\Database\Eloquent\Builder
* @param $query Builder
* @param $terms array
* @return Builder
*/
private function searchAttributes(Builder $query, array $terms)
{
@@ -107,8 +281,9 @@ trait Searchable
/**
* Searches the models custom fields for the search terms
*
* @param Illuminate\Database\Eloquent\Builder $query
* @return Illuminate\Database\Eloquent\Builder
* @param $query Builder
* @param $terms array
* @return Builder
*/
private function searchCustomFields(Builder $query, array $terms)
{
@@ -134,8 +309,9 @@ trait Searchable
/**
* Searches the models relations for the search terms
*
* @param Illuminate\Database\Eloquent\Builder $query
* @return Illuminate\Database\Eloquent\Builder
* @param $query Builder
* @param $terms array
* @return Builder
*/
private function searchRelations(Builder $query, array $terms)
{
@@ -188,9 +364,9 @@ trait Searchable
*
* This is a noop in this trait, but can be overridden in the implementing model, to allow more advanced searches
*
* @param Illuminate\Database\Eloquent\Builder $query
* @param array $terms The search terms
* @return Illuminate\Database\Eloquent\Builder
* @param $query Builder
* @param $terms array
* @return Builder
*
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
@@ -219,6 +395,14 @@ trait Searchable
return $this->searchableRelations ?? [];
}
/**
* Get searchable computed count aliases, if defined.
*/
private function getSearchableCounts(): array
{
return $this->searchableCounts ?? [];
}
/**
* Get the table name of a relation.
*
+11 -131
View File
@@ -167,6 +167,17 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
'groups' => ['name'],
'company' => ['name'],
'manager' => ['first_name', 'last_name', 'username', 'display_name'],
'adminuser' => ['first_name', 'last_name', 'display_name'],
];
protected $searchableCounts = [
'accessories_count',
'assets_count',
'licenses_count',
'consumables_count',
'accessories_count',
'manages_users_count',
'manages_locations_count',
];
/**
@@ -948,137 +959,6 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
return new \stdClass;
}
/**
* Query builder scope to search on text filters for complex Bootstrap Tables API
*
* @param \Illuminate\Database\Query\Builder $query Query builder instance
* @param text $filter JSON array of search keys and terms
* @return \Illuminate\Database\Query\Builder Modified query builder
*/
public function scopeByFilter($query, $filter)
{
return $query->where(
function ($query) use ($filter) {
foreach ($filter as $fieldname => $search_val) {
if ($fieldname == 'first_name') {
$query->where('users.first_name', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'last_name') {
$query->where('users.last_name', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'display_name') {
$query->where('users.display_name', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'name') {
$query->where('users.last_name', 'LIKE', '%'.$search_val.'%')
->orWhere('users.first_name', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'username') {
$query->where('users.username', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'email') {
$query->where('users.email', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'phone') {
$query->where('users.phone', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'mobile') {
$query->where('users.mobile', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'phone') {
$query->where('users.phone', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'jobtitle') {
$query->where('users.jobtitle', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'created_at') {
$query->where('users.created_at', '=', '%'.$search_val.'%');
}
if ($fieldname == 'updated_at') {
$query->where('users.updated_at', '=', '%'.$search_val.'%');
}
if ($fieldname == 'start_date') {
$query->where('users.start_date', '=', '%'.$search_val.'%');
}
if ($fieldname == 'end_date') {
$query->where('users.end_date', '=', '%'.$search_val.'%');
}
if ($fieldname == 'employee_num') {
$query->where('users.employee_num', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'locale') {
$query->where('users.locale', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'address') {
$query->where('users.address', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'state') {
$query->where('users.state', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'zip') {
$query->where('users.zip', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'country') {
$query->where('users.country', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'vip') {
$query->where('users.vip', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'remote') {
$query->where('users.remote', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'start_date') {
$query->where('users.purchase_date', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'notes') {
$query->where('users.notes', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'location') {
$query->whereHas(
'location', function ($query) use ($search_val) {
$query->where('locations.name', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'company') {
$query->whereHas(
'company', function ($query) use ($search_val) {
$query->where('companies.name', 'LIKE', '%'.$search_val.'%');
}
);
}
}
}
);
}
/**
* Query builder scope to search user by name with spaces in it.
* We don't use the advancedTextSearch() scope because that searches
+14 -13
View File
@@ -72,7 +72,7 @@ class AssetModelPresenter extends Presenter
],
[
'field' => 'min_amt',
'searchable' => false,
'searchable' => true,
'sortable' => true,
'switchable' => true,
'title' => trans('mail.min_QTY'),
@@ -83,7 +83,7 @@ class AssetModelPresenter extends Presenter
[
'field' => 'assets_count',
'searchable' => false,
'searchable' => true,
'sortable' => true,
'switchable' => true,
'title' => trans('admin/models/table.numassets'),
@@ -93,7 +93,7 @@ class AssetModelPresenter extends Presenter
],
[
'field' => 'assets_assigned_count',
'searchable' => false,
'searchable' => true,
'sortable' => true,
'switchable' => true,
'title' => trans('general.assigned'),
@@ -103,7 +103,7 @@ class AssetModelPresenter extends Presenter
],
[
'field' => 'remaining',
'searchable' => false,
'searchable' => true,
'sortable' => true,
'switchable' => true,
'title' => trans('general.remaining'),
@@ -114,15 +114,15 @@ class AssetModelPresenter extends Presenter
[
'field' => 'percent_remaining',
'searchable' => false,
'sortable' => false,
'sortable' => true,
'switchable' => true,
'title' => '% ' . trans('general.remaining'),
'title' => '% '.trans('general.remaining'),
'visible' => true,
'formatter' => 'progressBarFormatter',
],
[
'field' => 'assets_archived_count',
'searchable' => false,
'searchable' => true,
'sortable' => true,
'switchable' => true,
'title' => trans('general.archived'),
@@ -132,7 +132,7 @@ class AssetModelPresenter extends Presenter
],
[
'field' => 'depreciation',
'searchable' => false,
'searchable' => true,
'sortable' => true,
'switchable' => true,
'title' => trans('general.depreciation'),
@@ -158,7 +158,7 @@ class AssetModelPresenter extends Presenter
],
[
'field' => 'fieldset',
'searchable' => false,
'searchable' => true,
'sortable' => true,
'switchable' => true,
'title' => trans('admin/models/general.fieldset'),
@@ -192,7 +192,7 @@ class AssetModelPresenter extends Presenter
],
[
'field' => 'created_by',
'searchable' => false,
'searchable' => true,
'sortable' => true,
'title' => trans('general.created_by'),
'visible' => false,
@@ -290,8 +290,9 @@ class AssetModelPresenter extends Presenter
public function imageUrl()
{
if (! empty($this->image)) {
$url = Storage::disk('public')->url(app('models_upload_path') . e($this->image));
return '<img src="' . $url . '" alt="' . e($this->name) . '" height="50" width="50">';
$url = Storage::disk('public')->url(app('models_upload_path').e($this->image));
return '<img src="'.$url.'" alt="'.e($this->name).'" height="50" width="50">';
}
return '';
@@ -305,7 +306,7 @@ class AssetModelPresenter extends Presenter
public function imageSrc()
{
if (! empty($this->image)) {
return Storage::disk('public')->url(app('models_upload_path') . e($this->image));
return Storage::disk('public')->url(app('models_upload_path').e($this->image));
}
return '';
+1 -1
View File
@@ -108,7 +108,7 @@ class CategoryPresenter extends Presenter
'formatter' => 'usersLinkObjFormatter',
], [
'field' => 'created_at',
'searchable' => false,
'searchable' => true,
'sortable' => true,
'switchable' => true,
'title' => trans('general.created_at'),
+6 -6
View File
@@ -248,7 +248,7 @@ class UserPresenter extends Presenter
],
[
'field' => 'assets_count',
'searchable' => false,
'searchable' => true,
'sortable' => true,
'switchable' => true,
'escape' => true,
@@ -259,7 +259,7 @@ class UserPresenter extends Presenter
],
[
'field' => 'licenses_count',
'searchable' => false,
'searchable' => true,
'sortable' => true,
'switchable' => true,
'class' => 'css-license',
@@ -269,7 +269,7 @@ class UserPresenter extends Presenter
],
[
'field' => 'consumables_count',
'searchable' => false,
'searchable' => true,
'sortable' => true,
'switchable' => true,
'class' => 'css-consumable',
@@ -279,7 +279,7 @@ class UserPresenter extends Presenter
],
[
'field' => 'accessories_count',
'searchable' => false,
'searchable' => true,
'sortable' => true,
'switchable' => true,
'class' => 'css-accessory',
@@ -289,7 +289,7 @@ class UserPresenter extends Presenter
],
[
'field' => 'manages_users_count',
'searchable' => false,
'searchable' => true,
'sortable' => true,
'switchable' => true,
'class' => 'css-users',
@@ -299,7 +299,7 @@ class UserPresenter extends Presenter
],
[
'field' => 'manages_locations_count',
'searchable' => false,
'searchable' => true,
'sortable' => true,
'switchable' => true,
'class' => 'css-location',
@@ -29,6 +29,7 @@
buttons="categoryButtons"
fixed_right_number="1"
fixed_number="1"
show_advanced_search="true"
api_url="{{ route('api.categories.index') }}"
:presenter="\App\Presenters\CategoryPresenter::dataTableLayout()"
export_filename="export-categories-{{ date('Y-m-d') }}"
+1
View File
@@ -16,6 +16,7 @@
fixed_right_number="2"
fixed_number="1"
show_footer="true"
show_advanced_search="true"
name="licenses"
:route="route('api.licenses.index', ['status' => e(request('status'))])"/>
@@ -3,8 +3,10 @@
namespace Tests\Feature\Accessories\Api;
use App\Models\Accessory;
use App\Models\AccessoryCheckout;
use App\Models\Company;
use App\Models\User;
use Illuminate\Testing\Fluent\AssertableJson;
use Tests\Concerns\TestsFullMultipleCompaniesSupport;
use Tests\Concerns\TestsPermissionsRequirement;
use Tests\TestCase;
@@ -67,4 +69,31 @@ class IndexAccessoryTest extends TestCase implements TestsFullMultipleCompaniesS
->assertResponseContainsInRows($accessoryA)
->assertResponseContainsInRows($accessoryB);
}
public function test_can_filter_accessories_by_searchable_count_alias()
{
$this->markIncompleteIfSqlite('This test is not compatible with SQLite');
$user = User::factory()->viewAccessories()->create();
$targetAccessory = Accessory::factory()->create(['name' => 'Accessory With Two Checkouts']);
$otherAccessory = Accessory::factory()->create(['name' => 'Accessory With One Checkout']);
AccessoryCheckout::factory()->count(2)->create(['accessory_id' => $targetAccessory->id]);
AccessoryCheckout::factory()->create(['accessory_id' => $otherAccessory->id]);
$this->actingAsForApi($user)
->getJson(route('api.accessories.index', [
'filter' => json_encode(['checkouts_count' => 2]),
'sort' => 'id',
'order' => 'asc',
'offset' => '0',
'limit' => '20',
]))
->assertOk()
->assertJsonStructure([
'total',
'rows',
])
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->where('rows.0.name', 'Accessory With Two Checkouts')->etc());
}
}
@@ -2,6 +2,7 @@
namespace Tests\Feature\AssetModels\Api;
use App\Models\Asset;
use App\Models\AssetModel;
use App\Models\User;
use Illuminate\Testing\Fluent\AssertableJson;
@@ -62,4 +63,30 @@ class IndexAssetModelsTest extends TestCase
])
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc());
}
public function test_asset_model_index_filter_can_search_computed_count_aliases()
{
$this->markIncompleteIfSqlite('This test is not compatible with SQLite');
$targetModel = AssetModel::factory()->create(['name' => 'Two Assets Model']);
$otherModel = AssetModel::factory()->create(['name' => 'One Asset Model']);
Asset::factory()->count(2)->create(['model_id' => $targetModel->id]);
Asset::factory()->create(['model_id' => $otherModel->id]);
$this->actingAsForApi(User::factory()->superuser()->create())
->getJson(route('api.models.index', [
'filter' => '{"assets_count":"2"}',
'sort' => 'id',
'order' => 'asc',
'offset' => '0',
'limit' => '20',
]))
->assertOk()
->assertJsonStructure([
'total',
'rows',
])
->assertJson(fn (AssertableJson $json) => $json->has('rows', 1)->etc())
->assertJsonFragment(['name' => 'Two Assets Model']);
}
}
@@ -5,6 +5,7 @@ namespace Tests\Feature\Licenses\Api;
use App\Models\Company;
use App\Models\License;
use App\Models\User;
use Illuminate\Testing\Fluent\AssertableJson;
use Tests\TestCase;
class LicenseIndexTest extends TestCase
@@ -54,4 +55,76 @@ class LicenseIndexTest extends TestCase
->assertResponseDoesNotContainInRows($licenseA)
->assertResponseContainsInRows($licenseB);
}
public function test_returns_result_via_filter()
{
License::factory()->create(['name' => 'MY AWESOME LICENSE NAME 1']);
License::factory()->count(2)->create(['name' => 'MY AWESOME LICENSE NAME 2']);
License::factory()->count(2)->create(['name' => 'MY AWESOME LICENSE NAME 3']);
License::factory()->count(2)->create(['name' => 'MY TERRIBLE LICENSE NAME']);
$this->actingAsForApi(User::factory()->viewLicenses()->create())
->getJson(route('api.licenses.index', [
'filter' => '{"name":"AWESOME LICENSE NAME"}',
]))
->assertOk()
->assertJsonStructure([
'total',
'rows',
])
->assertJson(fn (AssertableJson $json) => $json->has('rows', 5)->etc());
}
public function test_returns_result_via_filter_for_manufacturer()
{
License::factory()->count(5)->office()->create();
License::factory()->count(3)->indesign()->create();
License::factory()->count(3)->acrobat()->create();
$this->actingAsForApi(User::factory()->viewLicenses()->create())
->getJson(route('api.licenses.index', [
'filter' => '{"manufacturer":"adobe"}',
]))
->assertOk()
->assertJsonStructure([
'total',
'rows',
])
->assertJson(fn (AssertableJson $json) => $json->has('rows', 6)->etc());
$this->actingAsForApi(User::factory()->viewLicenses()->create())
->getJson(route('api.licenses.index', [
'filter' => '{"manufacturer":"blah"}',
]))
->assertOk()
->assertJsonStructure([
'total',
'rows',
])
->assertJson(fn (AssertableJson $json) => $json->has('rows', 0)->etc());
$this->actingAsForApi(User::factory()->viewLicenses()->create())
->getJson(route('api.licenses.index', [
'filter' => '{"manufacturer":"microsoft"}',
]))
->assertOk()
->assertJsonStructure([
'total',
'rows',
])
->assertJson(fn (AssertableJson $json) => $json->has('rows', 5)->etc());
$this->actingAsForApi(User::factory()->viewLicenses()->create())
->getJson(route('api.licenses.index', [
'search' => 'adobe',
]))
->assertOk()
->assertJsonStructure([
'total',
'rows',
])
->assertJson(fn (AssertableJson $json) => $json->has('rows', 6)->etc());
}
}