diff --git a/app/Console/Commands/Purge.php b/app/Console/Commands/Purge.php index 4db7bac1472f..f57dc76a4d6d 100644 --- a/app/Console/Commands/Purge.php +++ b/app/Console/Commands/Purge.php @@ -12,6 +12,7 @@ use App\Models\License; use App\Models\Location; use App\Models\Manufacturer; +use App\Models\PredefinedFilter; use App\Models\Statuslabel; use App\Models\Supplier; use App\Models\User; @@ -178,6 +179,14 @@ public function handle() $this->info('- Status Label "'.$status_label->name.'" deleted.'); $status_label->forceDelete(); } + + $predefinedFilters = PredefinedFilter::whereNotNull('deleted_at')->withTrashed()->get(); + $this->info($predefinedFilters->count().' predefined filters purged.'); + foreach ($predefinedFilters as $predefinedFilter) { + $this->info('- Predefined Filter "'.$predefinedFilter->name.'" deleted.'); + $predefinedFilter->forceDelete(); + } + } else { $this->info('Action canceled. Nothing was purged.'); } diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index d68418ce59d6..cc66a080120a 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -162,6 +162,8 @@ public function render($request, Throwable $e) $route = 'licenses.index'; } elseif (($route === 'customfieldsets.index') || ($route === 'customfields.index')) { $route = 'fields.index'; + } elseif ($route === 'predefinedfilters.index') { + $route = 'predefined-filters.index'; } return redirect() diff --git a/app/Http/Controllers/Api/AssetsController.php b/app/Http/Controllers/Api/AssetsController.php index 614ad9b37244..740f9136c404 100644 --- a/app/Http/Controllers/Api/AssetsController.php +++ b/app/Http/Controllers/Api/AssetsController.php @@ -23,6 +23,7 @@ use App\Models\License; use App\Models\LicenseSeat; use App\Models\Location; +use App\Models\PredefinedFilter; use App\Models\Setting; use App\Models\User; use App\View\Label; @@ -145,11 +146,17 @@ public function index(FilterRequest $request, $action = null, $upcoming_status = if ($request->filled('filter')) { $filter = json_decode($request->input('filter'), true); + } - $filter = array_filter($filter, function ($key) use ($allowed_columns) { + if (!isset($filter[0]['field'])) { + $filter = array_filter($filter, function ($key) use ($allowed_columns){ return in_array($key, $allowed_columns); }, ARRAY_FILTER_USE_KEY); + } + $all_custom_fields = CustomField::all(); //used as a 'cache' of custom fields throughout this page load + foreach ($all_custom_fields as $field) { + $allowed_columns[] = $field->db_column_name(); } $assets = Asset::select('assets.*') @@ -409,6 +416,7 @@ public function index(FilterRequest $request, $action = null, $upcoming_status = break; case 'location': $assets->OrderLocation($order); + break; case 'rtd_location': $assets->OrderRtdLocation($order); break; @@ -453,6 +461,18 @@ public function index(FilterRequest $request, $action = null, $upcoming_status = break; } + // Filter with predefinedFilter if one is given + if (isset($request->predefinedFilter)) { + $id = $request->predefinedFilter; + $predefinedFilters = PredefinedFilter::where('id', $id) + ->where('created_by', auth()->user()->id) + ->first(); + + if ($predefinedFilters) { + $assets = $predefinedFilters->filterAssets($assets); + } + } + // Make sure the offset and limit are actually integers and do not exceed system limits $offset = ($request->input('offset') > $assets->count()) ? $assets->count() : app('api_offset_value'); @@ -1392,7 +1412,7 @@ public function getLabels(Request $request): JsonResponse $label = new Label(); - + if (!$label) { throw new \Exception('Label object could not be created'); } diff --git a/app/Http/Controllers/Api/GroupsController.php b/app/Http/Controllers/Api/GroupsController.php index 44ff9b98dc7d..c044440c6680 100644 --- a/app/Http/Controllers/Api/GroupsController.php +++ b/app/Http/Controllers/Api/GroupsController.php @@ -5,6 +5,7 @@ use App\Helpers\Helper; use App\Http\Controllers\Controller; use App\Http\Transformers\GroupsTransformer; +use App\Http\Transformers\SelectlistTransformer; use App\Models\Group; use Illuminate\Http\Request; use Illuminate\Http\JsonResponse; @@ -145,4 +146,33 @@ public function destroy($id) : JsonResponse return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/groups/message.delete.success'))); } + + /** + * Selectlist method that returns all groups wherere the user is member of + */ + public function selectlist(Request $request): array + { + $user = auth()->user(); + + $this->authorize('superadmin'); + $this->authorize('view', Group::class); + + // Start with groups the user belongs to + $groups = Group::whereHas('users', function ($query) use ($user) { + $query->where('user_id', $user->id); + }) + ->select('id', 'name'); + + // Search filter + if ($request->filled('search')) { + $groups = $groups->where('permission_groups.name', 'LIKE', '%' . $request->get('search') . '%'); + } + + // Apply sorting + $groups = $groups->orderBy('name', 'ASC')->paginate(50); + + // Transform output to match selectlist style + return (new SelectlistTransformer)->transformSelectlist($groups); + } + } diff --git a/app/Http/Controllers/Api/PredefinedFilterController.php b/app/Http/Controllers/Api/PredefinedFilterController.php new file mode 100644 index 000000000000..d7d5f47b6131 --- /dev/null +++ b/app/Http/Controllers/Api/PredefinedFilterController.php @@ -0,0 +1,180 @@ +service = $service; + } + + public function index(Request $request) : JsonResponse | array + { + $filters = $this->service->getAllViewableFilters(); + + if ($request->filled('search')) { + $search = strtolower($request->get('search')); + $filters = $filters->filter(fn($filter) => + str_contains(strtolower($filter->name), $search) + ); + } + + // --- Sorting --- + $sort = $request->input('sort', 'name'); + $order = $request->input('order', 'asc'); + + $allowedColumns = ['id', 'name', 'is_public', 'created_by']; + + if (!in_array($sort, $allowedColumns)) { + $sort = 'name'; + } + + $filters = $order === 'desc' + ? $filters->sortByDesc(fn($f) => strtolower(data_get($f, $sort, ''))) + : $filters->sortBy(fn($f) => strtolower(data_get($f, $sort, ''))); + + // --- Pagination --- + $total = $filters->count(); + $offset = (int) $request->input('offset', 0); + $limit = (int) $request->input('limit', config('app.max_results', 50)); + + $filters = $filters->slice($offset, $limit)->values(); + + return (new PredefinedFiltersTransformer)->transformPredefinedFilters($filters, $total); + } + + + + public function show(int $id) + { + $filter = $this->service->getFilterWithIdAndNameValues($id); + + if (!$filter) { + return response()->json(['message' => trans('admin/predefinedFilters/message.does_not_exist')], 404); + } + + if ($filter->userHasPermission(Auth::user(), 'view')) { + return response()->json($filter->toArray()); + } + + return response()->json(['message' => trans('admin/predefinedFilters/message.show.not_allowed')], 403); + } + + public function store(Request $request): JsonResponse | array + { + + $user = auth()->user(); + + $validator = Validator::make($request->all(), [ + 'name' => 'required|string|max:191', + 'filter_data' => 'required|array', + 'is_public' => 'sometimes|boolean' + ]); + + if ($validator->fails()) { + return response()->json(Helper::formatStandardApiResponse(422, null, $validator->errors()),422); + } + + $validated = $validator->validated(); + + if (!empty($validated['is_public']) && !$user->hasAccess('predefinedFilter.create')) { + return response()->json(['message' => trans('admin/predefinedFilters/message.create.not_allowed')], 403); + } + + $filter = $this->service->createFilter($validated); + + return response()->json([ + 'message' => trans('admin/predefinedFilters/message.create.success'), + 'filter_data' => $filter, + ], 201); + } + + public function update(Request $request, int $id): JsonResponse + { + $user = auth()->user(); + $filter = PredefinedFilter::find($id); + + if (!$filter) { + return response()->json(['message' => trans('admin/predefinedFilters/message.does_not_exist')], 404); + } + + $validator = Validator::make($request->all(), [ + 'name' => 'required|string|max:191', + 'filter_data' => 'required|array', + 'is_public' => 'sometimes|boolean' + ]); + + if ($validator->fails()) { + return response()->json(Helper::formatStandardApiResponse(422, null, $validator->errors()),422); + } + + $validated = $validator->validated(); + + $updatedPermission = $this->updatePermissions($validated, $filter, $user); + if ($updatedPermission !== null) { + return $updatedPermission; + } + + $updated = $this->service->updateFilter($filter, $validated); + + return response()->json([ + 'message' => trans('admin/predefinedFilters/message.update.success'), + 'filter_data' => $updated, + ]); + } + + public function destroy(int $id) + { + $user = auth()->user(); + $filter = PredefinedFilter::find($id); + + if (!$filter) { + return response()->json(['message' => trans('admin/predefinedFilters/message.does_not_exist')], 404); + } + + if ($filter->userHasPermission($user, 'delete')) { + $this->service->deleteFilter($filter); + return response()->json(['message' => trans('admin/predefinedFilters/message.delete.success')]); + } + + return response()->json(['message' => trans('admin/predefinedFilters/message.delete.not_allowed_to_delete')], 403); + } + + public function selectlist(Request $request) + { + $filters = $this->service->selectList($request, true); + return (new SelectlistTransformer)->transformSelectlist($filters); + } + + private function updatePermissions($validated, $filter, $user) { + $newIsPublic = $validated['is_public'] ?? $filter->is_public; + $currentIsPublic = $filter->is_public; + + if (!$filter->userHasPermission($user, 'edit')) { + return response()->json(['message' => trans('admin/predefinedFilters/message.not_allowed_to_edit')], 403); + } + + //create permission + if ((!$currentIsPublic && $newIsPublic) + && !$filter->userHasPermission($user, 'create')) { + return response()->json(['message' => trans('admin/predefinedFilters/message.update.not_allowed_to_change_isPublic')], 403); + } + + return null; + } +} diff --git a/app/Http/Controllers/Api/PredefinedFilterPermissionController.php b/app/Http/Controllers/Api/PredefinedFilterPermissionController.php new file mode 100644 index 000000000000..5f1400fca11b --- /dev/null +++ b/app/Http/Controllers/Api/PredefinedFilterPermissionController.php @@ -0,0 +1,75 @@ +service = $service; + } + + public function store(Request $request): JsonResponse + { + $this->authorize('edit', PredefinedFilter::class); + + $model = new PredefinedFilterPermission(); + $validated = $request->validate($model->getRules()); + + $filter = PredefinedFilter::findOrFail($validated['predefined_filter_id']); + $this->authorize('update', $filter); + + // Granular Permission + if (!$filter->userHasPermission($request->user(), 'edit')) { + return response()->json(['error' => 'Unauthorized'], 403); + } + + $permission = $this->service->store($validated); + + return response()->json([ + 'message' => __('admin/reports/message.create.success'), + 'data' => $permission, + ]); + } + + public function show(int $id): JsonResponse + { + $this->authorize('view', PredefinedFilter::class); + + $permission = $this->service->show($id); + + $filter = $permission->filter; + + if (!$filter) { + return response()->json(['message' => trans('NotFound')], 404); + } + + $this->authorize('view', $filter); + + return response()->json($permission); + } + + public function destroy(int $id): JsonResponse + { + $this->authorize('delete', PredefinedFilterPermission::class); + + $permission = PredefinedFilterPermission::findOrFail($id); + $this->authorize('delete', $permission->filter); + + $this->service->delete($id); + + return response()->json([ + 'message' => __('admin/reports/message.delete.success'), + ], 204); + } +} diff --git a/app/Http/Controllers/Assets/AssetsController.php b/app/Http/Controllers/Assets/AssetsController.php index d485fafa01a6..4b5736dee9af 100755 --- a/app/Http/Controllers/Assets/AssetsController.php +++ b/app/Http/Controllers/Assets/AssetsController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Assets; +use InvalidArgumentException; use App\Events\CheckoutableCheckedIn; use App\Helpers\Helper; use App\Http\Controllers\Controller; @@ -11,6 +12,7 @@ use App\Models\Actionlog; use App\Http\Requests\UploadFileRequest; use Illuminate\Support\Facades\Log; +use App\Models\AdvancedSearch; use App\Models\Asset; use App\Models\AssetModel; use App\Models\CheckoutRequest; @@ -19,6 +21,8 @@ use App\Models\Setting; use App\Models\Statuslabel; use App\Models\User; +use App\Models\PredefinedFilter; +use App\Services\PredefinedFilterService; use App\View\Label; use Carbon\Carbon; use Illuminate\Support\Facades\DB; @@ -43,12 +47,17 @@ */ class AssetsController extends Controller { + protected $qrCodeDimensions = ['height' => 3.5, 'width' => 3.5]; + protected $barCodeDimensions = ['height' => 2, 'width' => 22]; + + protected PredefinedFilterService $predefinedFilterService; - public function __construct() + public function __construct(PredefinedFilterService $predefinedFilterService) { $this->middleware('auth'); + $this->predefinedFilterService = $predefinedFilterService; parent::__construct(); } @@ -65,8 +74,38 @@ public function index(Request $request) : View { $this->authorize('index', Asset::class); $company = Company::find($request->input('company_id')); + $user = auth()->user(); + + $advancedSearchViewPermission = AdvancedSearch::userHasViewPermission($user); + $predefined_filter_id = $request->input('predefinedFilterId'); + + if ($advancedSearchViewPermission) { + // Validate if it's a valid integer + if (filter_var($predefined_filter_id, FILTER_VALIDATE_INT) === false && $predefined_filter_id != null) { + throw new InvalidArgumentException('You provided an invalid parameter for predefinedFilterId (must be an integer).'); + } + + $predefined_filter_name = ""; // Just an empty string to not fail other stuff because it is only needed when a predefined filter is set using the url + + if ($predefined_filter_id !== null) { + $filter = $this->predefinedFilterService->getFilterWithOptionalPermissionsById($predefined_filter_id); + if (!($filter)) { + $predefined_filter_id = null; + abort(404, "Predefined filter not found"); + } else { + $predefined_filter_name = $filter->name; + } + } + } else { + $predefined_filter_id = null; + $predefined_filter_name = null; + } - return view('hardware/index')->with('company', $company); + return view('hardware/index') + ->with('company', $company) + ->with('advanced_search_permission', $advancedSearchViewPermission) + ->with('predefined_filter_id', $predefined_filter_id) + ->with('predefined_filter_name', $predefined_filter_name); } /** diff --git a/app/Http/Controllers/PredefinedFilterController.php b/app/Http/Controllers/PredefinedFilterController.php new file mode 100644 index 000000000000..912a5a1700fc --- /dev/null +++ b/app/Http/Controllers/PredefinedFilterController.php @@ -0,0 +1,92 @@ +authorize('index', PredefinedFilter::class); + + $user = auth()->user(); + + $filters = PredefinedFilter::with('permissionGroups') + ->orderBy('name') + ->get() + ->filter(function ($filter) use ($user) { + return $filter->userHasPermission($user, 'view'); + }); + + return view('predefined-filters.index', compact('filters')); + } + + + /** + * Show the given Predefined Filter. + * + * @param PredefinedFilter + */ + public function view(PredefinedFilter $filter) : View|RedirectResponse + { + $user = auth()->user(); + + $filter = PredefinedFilter::find($filter->id); + + if (!$filter) { + return redirect()->back()->withErrors([ + 'message' => trans('admin/predefinedFilters/message.does_not_exist'), + ]); + } + + if ($filter->userHasPermission($user, 'view')) { + + return view('predefined-filters.view', compact('filter')); + } + + return redirect()->route('predefined-filters.index') + ->with('error', trans('admin/predefinedFilters/message.show.not_allowed')); + } + + /** + * Delete the given Predefined Filter. + * + * @param int $id + */ + public function destroy($id) : RedirectResponse + { + $user = auth()->user(); + + $filter = PredefinedFilter::find($id); + + if (!$filter) { + return redirect()->route('predefined-filters.index') + ->with('error', trans('admin/predefinedFilters/message.does_not_exist')); + } + + if ($filter->userHasPermission($user, 'delete')) { + $filter->delete(); + return redirect()->route('predefined-filters.index') + ->with('success', trans('admin/predefinedFilters/message.delete.success')); + } + + // It's public, so check permission logic + if ($filter->is_public) { + if (!$filter->userHasPermission($user, 'delete')) { + return redirect()->route('predefined-filters.index') + ->with('error', trans('general.insufficient_permissions')); + } + + $filter->delete(); + return redirect()->route('predefined-filters.index') + ->with('success', trans('admin/predefinedFilters/message.delete.success')); + } + + return redirect()->route('predefined-filters.index') + ->with('error', trans('general.insufficient_permissions')); + } +} diff --git a/app/Http/Transformers/PredefinedFiltersTransformer.php b/app/Http/Transformers/PredefinedFiltersTransformer.php new file mode 100644 index 000000000000..aeaa319ffaa8 --- /dev/null +++ b/app/Http/Transformers/PredefinedFiltersTransformer.php @@ -0,0 +1,67 @@ +transformDatatables($array, $total); + } + + public function transformPredefinedFilter($filter) + { + + $array = [ + 'id' => (int) $filter->id, + 'name'=> e($filter->name), + 'filter_data' => json_decode($filter->filter_data), + 'is_public' => (bool) $filter->is_public, + 'object_type' => e($filter->object_type), + 'created_by' => $filter->createdBy ? [ + 'id' => (int) $filter->createdBy->id, + 'name' => $filter->createdBy->present()->nameUrl(), + ] : null, + 'created_at' => Helper::getFormattedDateObject($filter->created_at, 'datetime'), + 'updated_at' => Helper::getFormattedDateObject($filter->updated_at, 'datetime'), + 'deleted_at' => Helper::getFormattedDateObject($filter->deleted_at, 'datetime'), + ]; + + if ($filter->relationLoaded('permissionGroups')) { + + $permissionGroups = $filter->permissionGroups; + + $groups = [ + 'total' => $permissionGroups->count(), + 'rows' => [] + ]; + + foreach ($permissionGroups as $group) { + $groups['rows'][] = [ + 'id' => $group->id, + 'name' => $group->name + ]; + } + $array['groups'] = $groups; + } else { + $array['groups'] = null; + } + + $permissionsArray = []; + + $permissionsArray['available_actions'] = [ + 'update' => $filter->userHasPermission(auth()->user(), 'edit'), + 'delete' => $filter->userHasPermission(auth()->user(), 'delete') + ]; + return $array += $permissionsArray; + } +} diff --git a/app/Http/Transformers/SelectlistTransformer.php b/app/Http/Transformers/SelectlistTransformer.php index 6972be262cfe..d1cd4d76203d 100644 --- a/app/Http/Transformers/SelectlistTransformer.php +++ b/app/Http/Transformers/SelectlistTransformer.php @@ -22,13 +22,19 @@ public function transformSelectlist(LengthAwarePaginator $select_items) // Loop through the paginated collection to set the array values foreach ($select_items as $select_item) { - $items_array[] = [ + $item = [ 'id' => (int) $select_item->id, 'text' => ($select_item->use_text) ? $select_item->use_text : $select_item->name, 'image' => ($select_item->use_image) ? $select_item->use_image : null, 'tag_color' => ($select_item->tag_color) ? $select_item->tag_color : null, ]; + + if (!empty($select_item->type)) { + $item['type'] = $select_item->type; + } + + $items_array[] = $item; } $results = [ diff --git a/app/Livewire/Partials/AdvancedSearch/Modal.php b/app/Livewire/Partials/AdvancedSearch/Modal.php new file mode 100644 index 000000000000..624d5e33a2e1 --- /dev/null +++ b/app/Livewire/Partials/AdvancedSearch/Modal.php @@ -0,0 +1,421 @@ +modalActionType = AdvancedsearchModalAction::from($action); + $this->showModal = true; + $this->groupSelect = []; + $this->groupSelectOtherOptions = []; + $this->filterData = $predefinedFilterData; + $this->filterId = $predefinedFilterId; + + $user = auth()->user(); + + // If the user a superuser show him all groups + if ($user->isSuperUser()) { + $this->groupSelectOtherOptions = PermissionGroup::all()->pluck("id")->toArray(); + + } else { + // Show only the groups there the user is member of + $this->groupSelectOtherOptions = $user + ->groups() + ->pluck("id") + ->toArray(); + } + + if ( + $this->modalActionType === AdvancedsearchModalAction::Edit + && $predefinedFilterId !== null + ) { + $this->openPredefinedFiltersEditModal($predefinedFilterService ,$predefinedFilterId); + } + + $this->dispatch("openPredefinedFiltersModalEvent"); + } + + private function openPredefinedFiltersEditModal(PredefinedFilterService $predefinedFilterService, $predefinedFilterId) { + $predefinedFilter = $predefinedFilterService->getFilterWithOptionalPermissionsById( + $predefinedFilterId + ); + + if ($predefinedFilter === null) { + $this->showModal = false; + $this->dispatchNotFoundNotification(); + return; + } + + $this->name = $predefinedFilter["name"]; + + if ($predefinedFilter["is_public"] == 1) { + $this->visibility = FilterVisibility::Public; + } else { + $this->visibility = FilterVisibility::Private; + } + + foreach ($predefinedFilter["permissions"] as $permission) { + array_push( + $this->groupSelect, + $permission->permission_group_id + ); + } + + $this->groupSelectOtherOptions = array_diff( + $this->groupSelectOtherOptions, + $this->groupSelect + ); + } + + #[On("closePredefinedFiltersModal")] + public function closePredefinedFiltersModal() + { + $this->showModal = false; + $this->groupSelect = []; + $this->groupSelectOtherOptions = []; + $this->filterData = null; + $this->filterId = null; + $this->name = ""; + $this->dispatch("closePredefinedFiltersModalEvent"); + } + + #[On("savePredefinedFiltersModal")] + public function savePredefinedFiltersModal( + PredefinedFilterService $predefinedFilterService + ) { + $this->validate(); + + if (!$this->validateMaxLenghtForFiltername()) { + return; + } + + $filter = new PredefinedFilter(); + + // Enforce: only allow creation if private or groups selected + if ($this->visibility === FilterVisibility::Public) { + + if (!$this->checkCreatePermissions()) { + $this->dispatch('showNotificationInFrontend', [ + 'type' => 'error', + 'title' => trans('general.notification_error'), + 'message' => trans('admin/predefinedFilters/message.create.not_allowed'), + 'tag' => 'predefinedFilter', + ]); + + $this->dispatch("closePredefinedFiltersModal"); + return; + } + + if (empty($this->groupSelect) || count($this->groupSelect) === 0) { + $this->dispatch('showNotificationInFrontend', [ + 'type' => 'error', + 'title' => trans('general.notification_error'), + 'message' => trans('admin/predefinedFilters/message.update.at_least_one_is_group_required_for_public_filter'), + 'tag' => 'predefinedFilter', + ]); + return; + } + }//end if + + if ($filter->checkIfNameAlreadyExists($this->name)) { + $this->dispatch('showNotificationInFrontend', [ + 'type' => 'warning', + 'title' => trans('general.notification_warning'), + 'message' => trans('admin/predefinedFilters/message.filter_duplicate_name'), + 'tag' => 'predefinedFilter', + ]); + } + + $validated = [ + "name" => $this->name, + "filter_data" => $this->filterData, + "is_public" => + $this->visibility === FilterVisibility::Public ? 1 : 0, + "permissions" => self::formatPermissions($this->groupSelect), + ]; + + $predefinedFilterService->createFilter($validated); + + $this->dispatch('showNotificationInFrontend', [ + 'type' => 'success', + 'title' => trans('general.notification_success'), + 'message' => trans('admin/predefinedFilters/message.create.success'), + 'tag' => 'predefinedFilter', + ]); + + $this->dispatch("savePredefinedFiltersModalEvent"); + $this->dispatch("closePredefinedFiltersModal"); + } + + #[On("updatePredefinedFiltersModal")] + public function updatePredefinedFiltersModal( + PredefinedFilterService $predefinedFilterService + ) { + $this->validate([ + 'name' => 'required|string', + 'filterData' => 'array', + 'groupSelect' => 'array', + 'groupSelect.*' => 'required|integer|exists:permission_groups,id', + ]); + + if(!$this->validateMaxLenghtForFiltername()) { + return; + } + + // Enforce: only allow update if private or groups selected + if ($this->visibility === FilterVisibility::Public && (empty($this->groupSelect) || count($this->groupSelect) === 0)) { + $this->dispatch('showNotificationInFrontend', [ + 'type' => 'error', + 'title' => trans('general.notification_error'), + 'message' => trans('admin/predefinedFilters/message.update.at_least_one_is_group_required_for_public_filter'), + 'tag' => 'predefinedFilter', + ]); + return; + } + + $predefinedFilter = PredefinedFilter::find($this->filterId); + + if (!isset($predefinedFilter)) { + $this->dispatch('showNotificationInFrontend', [ + 'type' => 'error', + 'title' => trans('general.notification_error'), + 'message' => trans('admin/predefinedFilters/message.does_not_exist'), + 'tag' => 'predefinedFilter', + ]); + return; + } + + if ($this->visibility === FilterVisibility::Public && !$predefinedFilter->is_public && !$this->checkCreatePermissions() ) { + + $this->dispatch('showNotificationInFrontend', [ + 'type' => 'error', + 'title' => trans('general.notification_error'), + 'message' => trans('admin/predefinedFilters/message.create.not_allowed'), + 'tag' => 'predefinedFilter', + ]); + + $this->dispatch("closePredefinedFiltersModal"); + return; + } + + if (!$predefinedFilter->userHasPermission(auth()->user(), 'edit')) { + $this->dispatch('showNotificationInFrontend', [ + 'type' => 'error', + 'title' => trans('general.notification_error'), + 'message' => trans('admin/predefinedFilters/message.update.not_allowed_to_edit'), + 'tag' => 'predefinedFilter', + ]); + $this->dispatch("updatePredefinedFiltersModalEvent"); + $this->dispatch("closePredefinedFiltersModal"); + return; + } + + $validated = [ + 'name' => $this->name ?? $predefinedFilter->name, + 'filter_data' => $this->filterData ?? $predefinedFilter->filter_data, + 'is_public' => isset($this->visibility) + ? ($this->visibility === FilterVisibility::Public ? 1 : 0) + : $predefinedFilter->is_public, + 'permissions' => self::formatPermissions($this->getGroupSelectArrayAsArray()), + ]; + + if ($predefinedFilter->checkIfNameAlreadyExists($this->name, $predefinedFilter->id)) { + $this->dispatch('showNotificationInFrontend', [ + 'type' => 'warning', + 'title' => trans('general.notification_warning'), + 'message' => trans('admin/predefinedFilters/message.filter_duplicate_name'), + 'tag' => 'predefinedFilter', + ]); + } + + $updateFilterResponse = $predefinedFilterService->updateFilter($predefinedFilter, $validated); + + if ($updateFilterResponse["validationErrors"] === null) { + $this->dispatch('showNotificationInFrontend', [ + 'type' => 'success', + 'title' => trans('general.notification_success'), + 'message' => trans('admin/predefinedFilters/message.update.success'), + 'tag' => 'predefinedFilter', + ]); + } else { + $this->dispatch('showNotificationInFrontend', [ + 'type' => 'error', + 'title' => trans('general.notification_error'), + 'message' => trans('admin/predefinedFilters/message.update.validation_error'), + 'tag' => 'predefinedFilter', + ]); + } + + $this->dispatch("updatePredefinedFiltersModalEvent"); + $this->dispatch("closePredefinedFiltersModal"); + } + + #[On("deletePredefinedFiltersModal")] + public function deletePredefinedFiltersModal( + PredefinedFilterService $predefinedFilterService + ) { + + + $predefinedFilter = $predefinedFilterService->getFilterWithOptionalPermissionsById($this->filterId); + + if ($predefinedFilter === null) { + $this->dispatchNotFoundNotification(); + return; + } + + if (!$predefinedFilter->userHasPermission(auth()->user(), 'delete')) { + $this->dispatch('showNotificationInFrontend', [ + 'type' => 'error', + 'title' => trans('general.notification_error'), + 'message' => trans('admin/predefinedFilters/message.delete.not_allowed_to_delete'), + 'tag' => 'predefinedFilter', + ]); + $this->dispatch("deletePredefinedFiltersModalEvent"); + $this->dispatch("closePredefinedFiltersModal"); + return; + } + + $deleteFilterResponse = $predefinedFilterService->deleteFilter($predefinedFilter); + + if ($deleteFilterResponse === true) { + $this->dispatch('showNotificationInFrontend', [ + 'type' => 'success', + 'title' => trans('general.notification_success'), + 'message' => trans('admin/predefinedFilters/message.delete.success'), + 'tag' => 'predefinedFilter', + ]); + } else { + $this->dispatch('showNotificationInFrontend', [ + 'type' => 'error', + 'title' => trans('general.notification_error'), + 'message' => trans('admin/predefinedFilters/message.delete.error'), + 'tag' => 'predefinedFilter', + ]); + } + + $this->dispatch("deletePredefinedFiltersModalEvent"); + $this->dispatch("closePredefinedFiltersModal"); + } + + public function updateGroupSelect($values) + { + $this->groupSelect = is_array($values) + ? $values + : ($values + ? [$values] + : []); + } + + public function render() + { + return view("livewire.partials.advancedsearch.modal"); + } + + private function getGroupSelectArrayAsArray(): array + { + if (is_array($this->groupSelect) === true) { + return $this->groupSelect; + } + return [$this->groupSelect]; + } + + private static function formatPermissions(array $permissions): array + { + $result = []; + + foreach ($permissions as $value) { + $result[] = ["permission_group_id" => $value]; + } + + return $result; + } + + private function checkCreatePermissions(): bool{ + $filter = new PredefinedFilter(); + + // create dummy filter + $filter->is_public = true; + $filter->filter_data = []; + $filter->created_by = auth()->user()->id; + + if ($filter->userHasPermission(auth()->user(), 'create')) { + return true; + } + + return false; + } + + private function dispatchNotFoundNotification() + { + $this->dispatch('showNotificationInFrontend', [ + 'type' => 'error', + 'title' => trans('general.notification_error'), + 'message' => trans('admin/predefinedFilters/message.does_not_exist'), + 'tag' => 'predefinedFilter', + ]); + } + + private function validateMaxLenghtForFiltername(): bool { + if (mb_strlen($this->name) > 190) { + $this->dispatch('showNotificationInFrontend', [ + 'type' => 'error', + 'title' => trans('general.notification_error'), + 'message' => trans('admin/predefinedFilters/message.name_too_long'), + 'tag' => 'predefinedFilter', + ]); + return false; + } + return true; + } + +} diff --git a/app/Models/AdvancedSearch.php b/app/Models/AdvancedSearch.php new file mode 100644 index 000000000000..93ae08d2169f --- /dev/null +++ b/app/Models/AdvancedSearch.php @@ -0,0 +1,12 @@ +hasAccess('advancedsearch'); + } +} diff --git a/app/Models/Asset.php b/app/Models/Asset.php index 46385c23304c..d79726a38d6f 100644 --- a/app/Models/Asset.php +++ b/app/Models/Asset.php @@ -14,6 +14,7 @@ use App\Models\Traits\Searchable; use App\Presenters\AssetPresenter; use App\Presenters\Presentable; +use App\Services\FilterService\FilterService; use Carbon\Carbon; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -32,6 +33,14 @@ class Asset extends Depreciable { + protected ?FilterService $filterService = null; + + public function filterService(): FilterService + { + return $this->filterService ??= app(FilterService::class); + + } + protected $presenter = AssetPresenter::class; protected $with = ['model', 'adminuser']; @@ -657,6 +666,28 @@ public function checkedOutToAsset(): bool return $this->assignedType() === self::ASSET; } + public function assignedToLocation() + { + return $this->belongsTo(Location::class, 'assigned_to') + ->where('assigned_type', '=', Location::class); + } + + public function assignedToUser() + { + return $this->belongsTo(User::class, 'assigned_to') + ->where('assigned_type', '=', User::class) + ->whereNotNull('assigned_to'); + } + + // Optional — only if an asset can be assigned to another asset + public function assignedToAsset() + { + return $this->belongsTo(Asset::class, 'assigned_to') + ->where('assigned_type', '=', Asset::class) + ->whereNotNull('assigned_to'); + } + + /** * Get the target this asset is checked out to * @@ -1843,11 +1874,11 @@ function ($query) use ($search) { * 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 + * @param text $filters JSON array of search keys and terms * * @return \Illuminate\Database\Query\Builder Modified query builder */ - public function scopeByFilter($query, $filter) + public function applyLegacyFilters($query, $filter) { return $query->where( function ($query) use ($filter) { @@ -2073,9 +2104,23 @@ function ($query) use ($search_val) { } ); + } + + public function scopeByFilter($query, array $filters) + { + // Check if the filters are in Snipe-IT's original format (key => value) + if ($this->isLegacyFilterFormat($filters)) { + return $this->applyLegacyFilters($query, $filters); + } + return $this->filterService()->searchByFilter($query, $filters); } + private function isLegacyFilterFormat($filters) + { + // Snipe-IT filters are simple key/value (not arrays with 'field') + return !isset($filters[0]['field']); + } /** * Query builder scope to order on model diff --git a/app/Models/PermissionGroup.php b/app/Models/PermissionGroup.php new file mode 100644 index 000000000000..d3e5f6caf28a --- /dev/null +++ b/app/Models/PermissionGroup.php @@ -0,0 +1,29 @@ +belongsToMany(User::class, 'users_groups', 'group_id', 'user_id'); + } + + public function predefinedFilterPermissions() + { + return $this->hasMany(PredefinedFilterPermission::class); + } +} diff --git a/app/Models/PredefinedFilter.php b/app/Models/PredefinedFilter.php new file mode 100644 index 000000000000..3d6823a47ba6 --- /dev/null +++ b/app/Models/PredefinedFilter.php @@ -0,0 +1,205 @@ + "array", + "is_public" => "boolean" + ]; + + protected $fillable = [ + 'name', + 'created_by', + 'filter_data', + 'is_public', + 'object_type', + ]; + + protected $rules = [ + 'name' => ['required', 'string', 'max:191'], + 'filter_data' => ['required', 'array'], + 'permissions' => ['sometimes', 'array'], + 'is_public' => 'sometimes|boolean' + ]; + + public function permissionGroups() + { + return $this->belongsToMany( + PermissionGroup::class, + 'predefined_filter_permissions', + 'predefined_filter_id', + 'permission_group_id' + ); + } + + public function createdBy() + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function userHasPermission(User $user, string $action): bool + { + // Give the superuser all permissions no matter in which groups he is + if ($user->isSuperUser()) { + return true; + } + + // If filter is private AND is_owner AND action != create he can do everything + // such as create private, edit and delete + // note the 'create' permission is only for creating public filters. + if ($user->id == $this->created_by && !$this->is_public && $action != 'create') { + return true; + } + + switch ($action) { + case 'create': + return $user->hasAccess('predefinedFilter.create'); + case 'view': + if ($this->checkPermissions($user, 'view')) { + return true; + } + //cascade for edit and view + return $this->userHasPermission($user, 'edit') || $this->userHasPermission($user, 'delete'); + case 'edit': + case 'delete': + // If filter is private AND is_owner AND action != create he can do everything + // such as create private, edit and delete + // note the 'create' permission is only for creating public filters. + if ($user->id == $this->created_by && !$this->is_public && $action != 'create') { + return true; + } + + return $this->checkPermissions($user, $action); + + default: + return false; + }//end switch + } + + private function checkPermissions(User $user, $action): bool + { + $userGroupIds = $user->groups()->pluck('id')->toArray(); + + if (!$user->relationLoaded('groups')) { + $user->load('groups'); + } + + foreach ($this->permissionGroups as $group) { + if (in_array($group->id, $userGroupIds)) { + $permissions = json_decode($group->permissions, true); + if (isset($permissions["predefinedFilter.$action"]) && $permissions["predefinedFilter.$action"] == '1') { + return true; + } + } + } + + return false; + } + + protected function applyArrayOrScalarFilter(Builder $assets, array $filter, string $key, string $column): void + { + if (!empty($filter[$key])) { + $values = is_array($filter[$key]) ? $filter[$key] : [$filter[$key]]; + $assets->whereIn($column, $values); + } + } + + protected function applyLikeFilter(Builder $assets, array $filter, string $key, string $column): void + { + if (!empty($filter[$key])) { + $assets->where($column, 'LIKE', '%' . $filter[$key] . '%'); + } + } + + protected function applyDateRangeFilter(Builder $assets, array $filter, string $field): void + { + $startKey = $field . '_start'; + $endKey = $field . '_end'; + + $start = $filter[$startKey] ?? null; + $end = $filter[$endKey] ?? null; + + + if (!$start && !$end) { + return; + } + + $table = $assets->getModel()->getTable(); + $column = $table . '.' . $field; + + if ($start) { + $assets->whereDate($column, '>=', $start); + } + + if ($end) { + $assets->whereDate($column, '<=', $end); + } + } + + + public function filterAssets(Builder $assets) + { + $filter = $this->filter_data ?? []; + + $this->applyArrayOrScalarFilter($assets, $filter, 'company_id', 'assets.company_id'); + $this->applyArrayOrScalarFilter($assets, $filter, 'location_id', 'location_id'); + $this->applyArrayOrScalarFilter($assets, $filter, 'rtd_location_id', 'rtd_location_id'); + $this->applyArrayOrScalarFilter($assets, $filter, 'supplier_id', 'supplier_id'); + $this->applyArrayOrScalarFilter($assets, $filter, 'model_id', 'model_id'); + $this->applyArrayOrScalarFilter($assets, $filter, 'status_id', 'status_id'); + + if (!empty($filter['category_id']) || !empty($filter['manufacturer_id'])) { + $assets->leftJoin('models', 'assets.model_id', '=', 'models.id'); + $this->applyArrayOrScalarFilter($assets, $filter, 'category_id', 'models.category_id'); + $this->applyArrayOrScalarFilter($assets, $filter, 'manufacturer_id', 'models.manufacturer_id'); + } + + $this->applyDateRangeFilter($assets, $filter, 'created_at'); + $this->applyDateRangeFilter($assets, $filter, 'purchase_date'); + $this->applyDateRangeFilter($assets, $filter, 'last_checkout'); + $this->applyDateRangeFilter($assets, $filter, 'last_checkin'); + $this->applyDateRangeFilter($assets, $filter, 'expected_checkin'); + $this->applyDateRangeFilter($assets, $filter, 'asset_eol_date'); + $this->applyDateRangeFilter($assets, $filter, 'last_audit_date'); + $this->applyDateRangeFilter($assets, $filter, 'next_audit_date'); + $this->applyDateRangeFilter($assets, $filter, 'updated_at'); + + $this->applyLikeFilter($assets, $filter, 'name', 'assets.name'); + $this->applyLikeFilter($assets, $filter, 'asset_tag', 'assets.asset_tag'); + $this->applyLikeFilter($assets, $filter, 'serial', 'assets.serial'); + + // Custom fields + if (!empty($filter['custom_fields']) && is_array($filter['custom_fields'])) { + foreach ($filter['custom_fields'] as $key => $value) { + $assets->where("assets.$key", '=', $value); + } + } + return $assets; + } + + public function checkIfNameAlreadyExists(string $name, int $id=null): bool + { + if ($id === null) { + $query = $this->where('name', '=', $name); + return $query->exists(); + } + + $query = $this->where('name', '=', $name); + $query->where('id', '<>', $id); + return sizeof($query->get()->toArray()) > 1; + + } +} diff --git a/app/Models/PredefinedFilterPermission.php b/app/Models/PredefinedFilterPermission.php new file mode 100644 index 000000000000..a81ca811d162 --- /dev/null +++ b/app/Models/PredefinedFilterPermission.php @@ -0,0 +1,39 @@ + ['required', 'integer', 'exists:users,id'], + 'predefined_filter_id' => ['required', 'integer', 'exists:predefined_filters,id'], + 'permission_group_id' => ['required', 'integer', 'exists:permission_groups,id'], + ]; + + /* + |-------------------------------------------------------------------------- + | Relationships + |-------------------------------------------------------------------------- + */ + + public function filter() + { + return $this->belongsTo(PredefinedFilter::class, 'predefined_filter_id'); + } +} diff --git a/app/Policies/AdvancedSearchPolicy.php b/app/Policies/AdvancedSearchPolicy.php new file mode 100644 index 000000000000..60abbae7e68e --- /dev/null +++ b/app/Policies/AdvancedSearchPolicy.php @@ -0,0 +1,14 @@ +userHasPermission($user, 'view'); + } + + return false; + } + + public function update(User $user, $filter=null) + { + if (parent::update($user, $filter)) { + return true; + } + + if ($filter instanceof PredefinedFilter) { + $filter->userHasPermission($user, 'edit'); + } + + return false; + } + + public function delete(User $user, $filter=null) + { + if (parent::delete($user, $filter)) { + return true; + } + + if ($filter instanceof PredefinedFilter) { + return $filter->userHasPermission($user, 'delete'); + } + + return false; + } + + public function create(User $user) + { + // Allow via global create permission + return parent::create($user) || true; + } +} diff --git a/app/Presenters/PredefinedFilterPresenter.php b/app/Presenters/PredefinedFilterPresenter.php new file mode 100644 index 000000000000..0fe719f3006b --- /dev/null +++ b/app/Presenters/PredefinedFilterPresenter.php @@ -0,0 +1,102 @@ + 'id', + 'searchable' => false, + 'sortable' => true, + 'switchable' => true, + 'title' => trans('general.id'), + 'visible' => false, + ], [ + 'field' => 'name', + 'searchable' => true, + 'sortable' => true, + 'switchable' => false, + 'title' => trans('general.name'), + 'visible' => true, + 'formatter' => 'predefinedFiltersLinkFormatter', + ], [ + 'field' => 'is_public', + 'searchable' => true, + 'sortable' => true, + 'switchable' => true, + 'title' => trans('general.is_public'), + 'visible' => true, + 'formatter' => 'trueFalseFormatter', + ], [ + 'field' => 'object_type', + 'searchable' => true, + 'sortable' => true, + 'switchable' => true, + 'title' => trans('object_type'), + 'visible' => false, + 'formatter' => 'predefined-filtersFormatter', + ], [ + 'field' => 'filter_data', + 'searchable' => true, + 'sortable' => true, + 'switchable' => true, + 'title' => trans('filter_data'), + 'visible' => false, + 'formatter' => 'predefined-filtersFormatter', + ], [ + 'field' => 'created_by', + 'searchable' => true, + 'sortable' => true, + 'title' => trans('general.created_by'), + 'visible' => true, + 'formatter' => 'usersLinkObjFormatter', + ], [ + 'field' => 'groups', + 'searchable' => false, + 'sortable' => false, + 'switchable' => true, + 'title' => trans('general.groups'), + 'visible' => true, + 'formatter' => 'groupsFormatter', + ], [ + 'field' => 'created_at', + 'searchable' => true, + 'sortable' => true, + 'switchable' => true, + 'title' => trans('general.created_at'), + 'visible' => false, + 'formatter' => 'dateDisplayFormatter', + ], [ + 'field' => 'updated_at', + 'searchable' => true, + 'sortable' => true, + 'switchable' => true, + 'title' => trans('general.updated_at'), + 'visible' => false, + 'formatter' => 'dateDisplayFormatter', + ], [ + 'field' => 'actions', + 'searchable' => false, + 'sortable' => false, + 'switchable' => false, + 'title' => trans('table.actions'), + 'formatter' => 'predefined-filtersActionsFormatter', + 'printIgnore' => true, + ] + ]; + + return json_encode($layout); + } +} diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 114100f2883f..8927dd36fca1 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -3,6 +3,7 @@ namespace App\Providers; use App\Models\Accessory; +use App\Models\AdvancedSearch; use App\Models\Asset; use App\Models\AssetModel; use App\Models\Category; @@ -16,11 +17,13 @@ use App\Models\License; use App\Models\Location; use App\Models\Manufacturer; +use App\Models\PredefinedFilter; use App\Models\PredefinedKit; use App\Models\Statuslabel; use App\Models\Supplier; use App\Models\User; use App\Policies\AccessoryPolicy; +use App\Policies\AdvancedSearchPolicy; use App\Policies\AssetModelPolicy; use App\Policies\AssetPolicy; use App\Policies\CategoryPolicy; @@ -34,6 +37,7 @@ use App\Policies\LicensePolicy; use App\Policies\LocationPolicy; use App\Policies\ManufacturerPolicy; +use App\Policies\PredefinedFilterPolicy; use App\Policies\PredefinedKitPolicy; use App\Policies\StatuslabelPolicy; use App\Policies\SupplierPolicy; @@ -54,6 +58,7 @@ class AuthServiceProvider extends ServiceProvider */ protected $policies = [ Accessory::class => AccessoryPolicy::class, + AdvancedSearch::class => AdvancedSearchPolicy::class, Asset::class => AssetPolicy::class, AssetModel::class => AssetModelPolicy::class, Category::class => CategoryPolicy::class, @@ -71,6 +76,7 @@ class AuthServiceProvider extends ServiceProvider User::class => UserPolicy::class, Manufacturer::class => ManufacturerPolicy::class, Company::class => CompanyPolicy::class, + PredefinedFilter::class => PredefinedFilterPolicy::class, ]; /** @@ -287,6 +293,7 @@ public function boot() || $user->can('create', Accessory::class) || $user->can('update', User::class) || $user->can('create', User::class) + || ($user->hasAccess('advancedsearch')) || ($user->hasAccess('reports.view')); }); diff --git a/app/Providers/BreadcrumbsServiceProvider.php b/app/Providers/BreadcrumbsServiceProvider.php index bc6d61d96899..f59f04306fac 100644 --- a/app/Providers/BreadcrumbsServiceProvider.php +++ b/app/Providers/BreadcrumbsServiceProvider.php @@ -18,6 +18,7 @@ use App\Models\Location; use App\Models\Manufacturer; use App\Models\PredefinedKit; +use App\Models\PredefinedFilter; use App\Models\Statuslabel; use App\Models\Supplier; use App\Models\User; @@ -485,6 +486,19 @@ public function boot() ->push(trans('general.breadcrumb_button_actions.edit_item', ['name' => $kit->name]), route('kits.edit', $kit)) ); + /** + * Predefined Filter Breadcrumbs + */ + Breadcrumbs::for('predefined-filters.index', fn (Trail $trail) => + $trail->parent('home', route('home')) + ->push(trans('general.predefined_filter'), route('predefined-filters.index')) + ); + + Breadcrumbs::for('predefined-filters.show', fn (Trail $trail, PredefinedFilter $filter) => + $trail->parent('predefined-filters.index', route('predefined-filters.index')) + ->push($filter->name, route('predefined-filters.show', $filter)) + ); + /** * Status Labels Breadcrumbs diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 054ffabe3e52..9bf3328c6dc3 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -48,6 +48,7 @@ protected function mapWebRoutes() require base_path('routes/web/locations.php'); require base_path('routes/web/consumables.php'); require base_path('routes/web/fields.php'); + require base_path('routes/web/predefined-filters.php'); require base_path('routes/web/components.php'); require base_path('routes/web/users.php'); require base_path('routes/web/kits.php'); diff --git a/app/Services/FilterService/FilterService.php b/app/Services/FilterService/FilterService.php new file mode 100644 index 000000000000..4431441759c3 --- /dev/null +++ b/app/Services/FilterService/FilterService.php @@ -0,0 +1,456 @@ + 'assets.asset_tag', + 'name' => 'assets.name', + 'serial' => 'assets.serial', + 'purchase_date' => 'assets.purchase_date', + 'purchase_cost' => 'assets.purchase_cost', + 'notes' => 'assets.notes', + 'order_number' => 'assets.order_number', + ]; + + protected array $relationMap = [ + 'model' => [ + 'relation' => 'model', + 'id' => 'models.id', + 'name' => 'models.name', + ], + 'category' => [ + 'relation' => 'model.category', + 'id' => 'categories.id', + 'name' => 'categories.name', + ], + 'manufacturer' => [ + 'relation' => 'model.manufacturer', + 'id' => 'manufacturers.id', + 'name' => 'manufacturers.name', + ], + 'company' => [ + 'relation' => 'company', + 'id' => 'companies.id', + 'name' => 'companies.name', + ], + 'supplier' => [ + 'relation' => 'supplier', + 'id' => 'suppliers.id', + 'name' => 'suppliers.name', + ], + 'location' => [ + 'relation' => 'location', + 'id' => 'locations.id', + 'name' => 'locations.name', + ], + 'rtd_location' => [ + 'relation' => 'defaultLoc', + 'id' => 'locations.id', + 'name' => 'locations.name', + ], + 'status_label' => [ + 'relation' => 'assetstatus', + 'id' => 'status_labels.id', + 'name' => 'status_labels.name', + ], + 'model_number' => [ + 'relation' => 'model', + 'column' => 'models.model_number', + ], + 'jobtitle' => [ + 'relation' => 'assignedTo', + 'morph' => true, + 'type' => User::class, + 'column' => 'users.jobtitle', + ], + ]; + + public function searchByFilter($query, $filters) + { + return $query->where(function (Builder $query) use ($filters) { + + $this->applyDateRangeFilter($query, 'assets.purchase_date', $filters, false); + $this->applyDateRangeFilter($query, 'assets.asset_eol_date', $filters, false); + + $this->applyDateRangeFilter($query, 'assets.created_at', $filters, true); + $this->applyDateRangeFilter($query, 'assets.updated_at', $filters, true); + + foreach ($filters as $filterItem) { + if (!isset($filterItem['field'], $filterItem['operator'], $filterItem['logic'], $filterItem['value'])) { + continue; + } + if ($filterItem['value'] === ['']) { + continue; + } + if (in_array($filterItem['field'], $this->skipFields, true)) { + continue; + } + + $this->applySingleFilter($query, $filterItem); + } + }); + } + + /** + * Apply a single filter object into the query builder, using operator & logic. + * + * @param Builder $q + * @param array $filterObj keys: field, value, operator, logic + * @return void + */ + + protected function applySingleFilter(Builder &$q, array $filterObj) + { + $fieldname = $filterObj['field']; + $value = $filterObj['value']; + $operator = strtolower($filterObj['operator'] ?? 'equals'); // "equals" or "contains" + $logic = strtoupper($filterObj['logic'] ?? 'AND'); // "AND", "NOT" + + $callback = function (Builder $inner) use ($fieldname, $value, $operator) { + + // === 1. Simple Fields === + if (array_key_exists($fieldname, $this->simpleFields)) { + $column = $this->simpleFields[$fieldname]; + + $this->applyWhereWithOperator($inner, $column, $value, $operator); + return; + } + + // === 2. Relational or Morph === + if (isset($this->relationMap[$fieldname])) { + $meta = $this->relationMap[$fieldname]; + + // --- Morph Relation --- + if (!empty($meta['morph'])) { + $this->applyMorphRelation($inner, $meta, $value, $operator); + + return; + } + + // --- Normal Relation --- + $this->applyNormalRelation($meta, $inner, $value, $operator); + + return; + } + + + // // === 3. Handle assignedTo === + if ($fieldname === 'assigned_to') { + $this->handleAssignedTo($value, $inner, $operator); + return; + } + + // === 4. Direct column - CustomFields === + $column = 'assets.' . $fieldname; + + if (!Schema::hasColumn('assets', $fieldname)) { + return; + } + + $this->applyWhereWithOperator($inner, $column, $value, $operator); + }; + + // === Apply logic === + $this->applyLogic($logic, $q, $callback, $fieldname); + } + + protected function applyNormalRelation($meta, $inner, $value, $operator){ + $relationPath = explode('.', $meta['relation']); + $first = array_shift($relationPath); + + $inner->whereHas($first, function ($subQ) use ($relationPath, $value, $operator, $meta) { + foreach ($relationPath as $relation) { + $subQ->whereHas($relation, function ($q) use ($value, $operator, $meta) { + $this->applyRelationalValue($q, $value, $operator, $meta); + }); + } + + if (empty($relationPath)) { + $this->applyRelationalValue($subQ, $value, $operator, $meta); + } + }); + } + + protected function applyMorphRelation($inner, $meta, $value, $operator){ + + $types = $meta['types'] ?? [$meta['type']]; + + $inner->where(function ($q2) use ($types, $value, $operator, $meta) { + foreach ($types as $type) { + $q2->orWhereHasMorph($meta['relation'], [$type], function ($morphQ) use ($type, $value, $operator, $meta) { + if ($meta['column'] ?? false) { + $field = $meta['column']; + if (is_array($value)) { + $morphQ->whereIn($field, $value); + } else { + $morphQ->where($field, $operator === 'equals' ? '=' : 'LIKE', $operator === 'equals' ? $value : '%' . $value . '%'); + } + } else { + if ($type === User::class) { + $morphQ->where(function ($sq) use ($value) { + $sq->where('first_name', 'LIKE', '%' . $value . '%') + ->orWhere('last_name', 'LIKE', '%' . $value . '%'); + }); + } else { + $morphQ->where('name', 'LIKE', '%' . $value . '%'); + } + } + }); + }//end foreach + }); + } + + protected function applyAssignedToLocation($inner, $value, $operator){ + $inner->where(function ($query) use ($value, $operator) { + $query->whereHas('assignedToLocation', function ($q) use ($value, $operator) { + $this->applyRelationalValue($q, $value['value'], $operator, ['column' => 'locations.name']); + }); + }); + } + + protected function handleAssignedTo($value, $inner, $operator){ + + // Check if type is valid + $validTypes = [Asset::class, Location::class, User::class]; + if (!in_array($value['type'], $validTypes)) { + throw new UnexpectedValueException('You\'ve provided an invalid type'); + } + + if ($value['value'] == '') { + return; + } + + // === 3a. Handle assignedTo location === + if ($value['type'] === Location::class) { + $this->applyAssignedToLocation($inner, $value, $operator); + } + + // === 3b. Handle assignedTo asset === + else if ($value['type'] === Asset::class) { + $assignedValue = $value['value']; + + $this->applyAssignedToAsset($inner, $assignedValue, $operator); + } + // === 3c. Handle assignedTo user === + else if ($value['type'] === User::class) { + $assignedValue = trim((string) ($value['value'] ?? '')); + + $this->applyAssignedToUser($inner, $assignedValue, $operator); + } + } + + protected function applyAssignedToUser($inner, $assignedValue, $operator){ + // Non-empty search: split into tokens + $tokens = preg_split('/\s+/', $assignedValue, -1, PREG_SPLIT_NO_EMPTY); + $inner->where(function ($q) use ($tokens, $operator) { + $q->whereHas('assignedToUser', function ($qq) use ($tokens, $operator) { + if (count($tokens) === 1) { + $term = $tokens[0]; + + // single token: match first_name OR last_name + $qq->where(function ($r) use ($term, $operator) { + $this->applyRelationalValue($r, $term, $operator, ['column' => 'users.first_name']); + })->orWhere(function ($r) use ($term, $operator) { + $this->applyRelationalValue($r, $term, $operator, ['column' => 'users.last_name']); + }); + return; + } + + // multiple tokens: first => first_name, rest => last_name + $first = array_shift($tokens); + $last = implode(' ', $tokens); + + $qq->where(function ($r) use ($first, $operator) { + $this->applyRelationalValue($r, $first, $operator, ['column' => 'users.first_name']); + })->where(function ($r) use ($last, $operator) { + $this->applyRelationalValue($r, $last, $operator, ['column' => 'users.last_name']); + }); + }); + }); + } + protected function applyAssignedToAsset($inner, $assignedValue, $operator){ + // Ensure parent asset has an assigned asset and the assigned type is Asset. + // Use a whereExists subquery that selects a real column (b.id) — no DB::raw required. + $inner->where(function ($q) use ($assignedValue, $operator) { + $q->whereNotNull('assets.assigned_to') + ->where('assets.assigned_type', Asset::class) + ->whereExists(function ($sub) use ($assignedValue, $operator) { + $sub->from('assets as b') + ->select('b.id') + ->whereColumn('b.id', 'assets.assigned_to') + ->where(function ($q2) use ($assignedValue, $operator) { + if ($operator === 'equals') { + $q2->where('b.asset_tag', '=', $assignedValue) + ->orWhere('b.name', '=', $assignedValue); + } else { + $q2->where('b.asset_tag', 'LIKE', '%' . $assignedValue . '%') + ->orWhere('b.name', 'LIKE', '%' . $assignedValue . '%'); + } + }); + }); + }); + } + + protected function applyRelationalValue(Builder $q, $value, string $operator, array $meta): void + { + $idField = $meta['id'] ?? null; + $nameField = $meta['name'] ?? null; + $column = $meta['column'] ?? null; + + if ($column) { + $this->applyWhereWithOperator($q, $column, $value, $operator); + return; + } + + // Fallback safety check + if (!$idField && !$nameField) { + return; + } + $values = is_array($value) ? $value : [$value]; + + $ids = array_filter($values, 'is_int'); + $names = array_filter($values, 'is_string'); + + $q->where(function ($subQ) use ($ids, $names, $idField, $nameField, $operator) { + $first = true; + + // IDs only + if (!empty($ids)) { + if ($first) { + $subQ->whereIn($idField, $ids); + $first = false; + } else { + $subQ->orWhereIn($idField, $ids); + } + } + + // Names only + foreach ($names as $name) { + if ($first) { + $this->applyWhereWithOperator($subQ, $nameField, $name, $operator); + $first = false; + } else { + $subQ->orWhere(function ($q) use ($nameField, $name, $operator) { + $this->applyWhereWithOperator($q, $nameField, $name, $operator); + }); + } + } + }); + } + + protected function applyWhereWithOperator(Builder $query, string $column, $value, string $operator) + { + $value = is_array($value) ? $value : [$value]; + + if ($operator === 'equals') { + $query->whereIn($column, $value); + } else { + $query->where(function ($q) use ($column, $value) { + foreach ($value as $v) { + $q->orWhere($column, 'LIKE', '%' . $v . '%'); + } + }); + } + } + + protected function applyLogic($logic, $q, $callback, $fieldname){ + switch ($logic) { + case 'NOT': + $q->where(function ($outer) use ($callback, $fieldname) { + $outer->whereNot($callback); + + // Only add "OR IS NULL" for direct columns (not relationships) + if (!Str::contains($fieldname, '.') && Schema::hasColumn('assets', $fieldname)) { + $outer->orWhereNull('assets.' . $fieldname); + } + }); + break; + case 'AND': + default: + $q->where($callback); + break; + } + } + + /** + * + * + * @param Builder $query + * @param string $qualifiedField + * @param array $filters + * @param bool $isDateTime + */ + + public function applyDateRangeFilter($query, $qualifiedField, $filters, bool $isDateTime=false) + { + $start = null; + $end = null; + + $fieldNameOnly = \Illuminate\Support\Str::afterLast($qualifiedField, '.'); + + foreach ($filters as $filter) { + if (!isset($filter['field'], $filter['value']) || !is_array($filter['value'])) { + continue; + } + if ($filter['field'] !== $fieldNameOnly) { + continue; + } + if (array_key_exists('startDate', $filter['value'])) { + $start = $filter['value']['startDate']; + } + if (array_key_exists('endDate', $filter['value'])) { + $end = $filter['value']['endDate']; + } + } + + if (!$start && !$end) { + return $query; + } + + if ($isDateTime) { + + if ($start && $end) { + $query->whereBetween($qualifiedField, [ + \Carbon\Carbon::parse($start)->startOfDay(), + \Carbon\Carbon::parse($end)->endOfDay(), + ]); + } else if ($start) { + $query->where($qualifiedField, '>=', \Carbon\Carbon::parse($start)->startOfDay()); + } else if ($end) { + $query->where($qualifiedField, '<=', \Carbon\Carbon::parse($end)->endOfDay()); + } + } else { + if ($start && $end) { + $query->whereBetween($qualifiedField, [ + \Carbon\Carbon::parse($start)->toDateString(), + \Carbon\Carbon::parse($end)->toDateString(), + ]); + } else if ($start) { + $query->whereDate($qualifiedField, '>=', \Carbon\Carbon::parse($start)->toDateString()); + } else if ($end) { + $query->whereDate($qualifiedField, '<=', \Carbon\Carbon::parse($end)->toDateString()); + } + } + + return $query; + }//end if +} diff --git a/app/Services/PredefinedFilterPermissionService.php b/app/Services/PredefinedFilterPermissionService.php new file mode 100644 index 000000000000..3e0edaebab54 --- /dev/null +++ b/app/Services/PredefinedFilterPermissionService.php @@ -0,0 +1,50 @@ +predefined_filter_id = $validated['predefined_filter_id']; + $permission->permission_group_id = $validated['permission_group_id']; + $permission->created_by = $userId; + if (!$permission->save()) { + Log::error($permission->getErrors()); + } + + return $permission; + } + + public function show(int $id): PredefinedFilterPermission + { + return PredefinedFilterPermission::with('filter')->findOrFail($id)->distinct(); + } + + public function delete(int $id): void + { + $permission = PredefinedFilterPermission::findOrFail($id); + $permission->delete(); + } + + public function deletePermissionByFilterId($filterId): void + { + $permissions = PredefinedFilterPermission::where('predefined_filter_id', '=', $filterId)->get(); + foreach ($permissions as $permission) { + $permission->delete(); + } + } + + public function getPermissionsByPredefinedFilterId(int $filterId) + { + return PredefinedFilterPermission::where('predefined_filter_id', '=', $filterId)->get(); + } +} diff --git a/app/Services/PredefinedFilterService.php b/app/Services/PredefinedFilterService.php new file mode 100644 index 000000000000..f1478d6fabcc --- /dev/null +++ b/app/Services/PredefinedFilterService.php @@ -0,0 +1,290 @@ +predefinedFilterPermissionService = $predefinedFilterPermissionService; + } + + protected ?FilterService $filterService = null; + + public function filterService(): FilterService + { + return $this->filterService ??= app(FilterService::class); + } + + public function getAllViewableFilters(): Collection + { + $user = Auth::user(); + + return PredefinedFilter::with('permissionGroups') + ->orderBy('name') + ->get(['id', 'name', 'created_by', 'is_public']) + ->filter(function ($filter) use ($user) { + + if ($filter->userHasPermission($user, 'view')) { + return true; + } + + return false; + })->values(); + } + + public function getFilterWithOptionalPermissionsById(int $id, bool $includePredefinedFilterGroups=true) + { + $predefinedFilter = PredefinedFilter::find($id); + if ($includePredefinedFilterGroups && $predefinedFilter) { + $permissions = $this->predefinedFilterPermissionService->getPermissionsByPredefinedFilterId($id); + $predefinedFilter['permissions'] = $permissions; + } + + return $predefinedFilter; + } + + public function getFilterWithIdAndNameValues(int $id) + { + $predefinedFilter = $this->getFilterWithOptionalPermissionsById($id); + + if (!$predefinedFilter) { + return null; + } + + $fieldsToLookup = [ + 'company', + 'model', + 'category', + 'status_label', + 'location', + 'rtd_location', + 'manufacturer', + 'supplier' + ]; + + $filters = $predefinedFilter->filter_data; + + foreach ($filters as &$filter) { + + $model = null; + + if (isset($filter['field']) && !in_array($filter['field'], $fieldsToLookup)) { + continue; + } + + if (!empty($filter['value']) && is_array($filter['value']) && is_int($filter['value'][0])) { + + $values =[]; + + foreach ($filter['value'] as $valueId) { + switch ($filter['field']) { + case 'company': + $model = Company::find($valueId); + break; + case 'model': + $model = AssetModel::find($valueId); + break; + case 'category': + $model = Category::find($valueId); + break; + case 'status_label': + $model = Statuslabel::find($valueId); + break; + case 'location': + case 'rtd_location': + $model = Location::find($valueId); + break; + case 'manufacturer': + $model = Manufacturer::find($valueId); + break; + case 'supplier': + $model = Supplier::find($valueId); + break; + default: + break; + }//end switch + + if ($model) { + $values[] = [ + 'id' => $model->id, + 'name' => $model->name + ]; + } + $filter['value'] = $values; + }//end foreach + + } + } + $predefinedFilter->filter_data = $filters; + return $predefinedFilter; + } + + public function createFilter($validated): PredefinedFilter + { + $createResponse = PredefinedFilter::create([ + 'name' => $validated['name'], + 'filter_data' => $validated['filter_data'], + 'created_by' => Auth::id(), + 'is_public' => $validated['is_public'] ?? 0, + ]); + + // Set permissions + if (array_key_exists('permissions', $validated) && count($validated['permissions']) > 0) { + foreach ($validated['permissions'] as $permission) { + $permission['predefined_filter_id'] = $createResponse->id; + $this->predefinedFilterPermissionService->store($permission); + } + } + + return $createResponse; + } + + public function updateFilter(PredefinedFilter $filter, array $validated): PredefinedFilter + { + $filter->fill([ + 'name' => $validated['name'], + 'filter_data' => $validated['filter_data'], + 'is_public' => $validated['is_public'], + ]); + + $filter->save(); + + // Update permissions + if (array_key_exists('permissions', $validated)) { + $currentlySetPermssions = $this->predefinedFilterPermissionService->getPermissionsByPredefinedFilterId($filter->id); + $newPermissions = $validated['permissions']; + $permissionDiff = $this->syncPermissions($currentlySetPermssions->toArray(), $newPermissions); + + try { + DB::transaction(function () use ($permissionDiff, $filter) { + if (!empty($permissionDiff['to_delete'])) { + foreach ($permissionDiff['to_delete'] as $permission) { + $this->predefinedFilterPermissionService->deletePermissionByFilterId($permission['predefined_filter_id']); + } + } + + if (!empty($permissionDiff['to_add'])) { + foreach ($permissionDiff['to_add'] as $permission) { + $permission['predefined_filter_id'] = $filter->id; + $this->predefinedFilterPermissionService->store($permission); + } + } + }); + } catch (Throwable $e) { + // If any exception occurs, the transaction is automatically rolled back. + Log::error($e->getMessage()); + + } + } + + return $filter; + } + + public function deleteFilter(PredefinedFilter $filter): ?bool + { + return $filter->delete(); + } + + public function selectList(Request $request, bool $visibilityInName=false): LengthAwarePaginator + { + $user = Auth::user(); + + $filters = PredefinedFilter::with("permissionGroups") + ->orderBy('name') + ->get(['id', 'name', 'created_by', 'is_public']); + + $viewableFilters = $filters->filter(fn($f) => $f->userHasPermission($user, 'view')) + ->pluck('id'); + + $query = PredefinedFilter::select(['id', 'name', 'is_public']) + ->whereIn('id', $viewableFilters); + + $this->applySearchFilter($query, $request); + + $paginated = $query->orderBy('name')->paginate(50); + + foreach ($paginated as $item) { + $item->use_text = $visibilityInName + ? $item->name . ' (' . $this->getVisibilityAsLocalizedString($item->is_public) . ')' + : $item->name; + } + + return $paginated; + } + + protected function applySearchFilter($query, Request $request): void + { + if (!$request->filled('search')) { + return; + } + + $search = trim($request->get('search', '')); + $upper = strtoupper($search); + + $private = strtoupper(trans('general.private')) . ':'; + $public = strtoupper(trans('general.public')) . ':'; + + if (str_starts_with($upper, 'PRIVATE:') || str_starts_with($upper, $private)) { + $query->where('is_public', 0); + $search = preg_replace('/^(PRIVATE:|' . preg_quote($private, '/') . ')/i', '', $search); + + } else if (str_starts_with($upper, 'PUBLIC:') || str_starts_with($upper, $public)) { + $query->where('is_public', 1); + $search = preg_replace('/^(PUBLIC:|' . preg_quote($public, '/') . ')/i', '', $search); + } + + $query->where('name', 'LIKE', '%' . trim($search) . '%'); + } + + private function syncPermissions($currentPermissions, $newPermissions): array + { + $toAdd = array_udiff( + $newPermissions, + $currentPermissions, + function ($a, $b) { + return $a['permission_group_id'] <=> $b['permission_group_id']; + } + ); + + $toDelete = array_udiff( + $currentPermissions, + $newPermissions, + function ($a, $b) { + return $a['permission_group_id'] <=> $b['permission_group_id']; + } + ); + + return [ + 'to_add' => $toAdd, + 'to_delete' => $toDelete + ]; + } + + private function getVisibilityAsLocalizedString(bool $isPublic): string + { + return $isPublic == true ? trans('general.public') : trans('general.private'); + } +} diff --git a/bootstrap/cache/.gitignore b/bootstrap/cache/.gitignore old mode 100644 new mode 100755 diff --git a/config/permissions.php b/config/permissions.php index 2acf851d8e47..add2efa0f5ab 100644 --- a/config/permissions.php +++ b/config/permissions.php @@ -234,6 +234,13 @@ ], ], + 'AdvancedSearch' => [ + [ + 'permission' => 'advancedSearch.view', + 'display' => true, + ], + ], + 'Users' => [ [ 'permission' => 'users.view', @@ -446,6 +453,24 @@ ], ], + 'PredefinedFilters' => [ + [ + 'permission' => 'predefinedFilter.view', + 'display' => true, + ], + [ + 'permission' => 'predefinedFilter.create', + 'display' => true, + ], + [ + 'permission' => 'predefinedFilter.edit', + 'display' => true, + ], + [ + 'permission' => 'predefinedFilter.delete', + 'display' => true, + ], + ], 'User (Self) Accounts' => [ [ diff --git a/database/factories/AssetFactory.php b/database/factories/AssetFactory.php index ca85c2358442..23de44df346f 100644 --- a/database/factories/AssetFactory.php +++ b/database/factories/AssetFactory.php @@ -72,6 +72,7 @@ public function laptopMbp() 'model_id' => function () { return AssetModel::where('name', 'Macbook Pro 13"')->first() ?? AssetModel::factory()->mbp13Model(); }, + 'company_id' => \App\Models\Company::inRandomOrder()->value('id'), ]; }); } diff --git a/database/factories/PermissionGroupFactory.php b/database/factories/PermissionGroupFactory.php new file mode 100644 index 000000000000..edf7886b90c6 --- /dev/null +++ b/database/factories/PermissionGroupFactory.php @@ -0,0 +1,22 @@ + $this->faker->unique()->words(2, true), + 'permissions' => json_encode([]), + 'created_by' => null, + 'notes' => $this->faker->optional()->sentence(), + ]; + } +} diff --git a/database/factories/PredefinedFilterFactory.php b/database/factories/PredefinedFilterFactory.php new file mode 100644 index 000000000000..8b0fb1127107 --- /dev/null +++ b/database/factories/PredefinedFilterFactory.php @@ -0,0 +1,49 @@ + + */ +class PredefinedFilterFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + + + public function definition(): array + { + return [ + 'name' => $this->faker->name, + 'created_by' => User::factory(), + 'filter_data' => ['company_id' => [1]], + ]; + } + + public function company(Company $company, User $user) { + return $this->state(fn () => [ + 'name' => $company->name, + 'created_by' => $user->id, + 'company_id' => $company->id, + 'notes' => 'Created by DB seeder', + ]); + } + + public function category(Category $category, User $user) { + return $this->state(fn () => [ + 'name' => $category->name, + 'created_by' => $user->id, + 'company_id' => $category->id, + 'notes' => 'Created by DB seeder', + ]); + } +} diff --git a/database/migrations/2025_08_29_120952_create_predefined_filters_table.php b/database/migrations/2025_08_29_120952_create_predefined_filters_table.php new file mode 100644 index 000000000000..ba845027be0b --- /dev/null +++ b/database/migrations/2025_08_29_120952_create_predefined_filters_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('name'); + $table->unsignedInteger('created_by'); + $table->timestamp('created_at')->nullable()->useCurrent(); + $table->timestamp('updated_at')->nullable()->useCurrent()->useCurrentOnUpdate(); + $table->softDeletes(); + $table->longText('filter_data'); + $table->boolean('is_public')->default(false); + $table->string('object_type')->nullable()->default('asset'); + }); + } + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('predefined_filters'); + } +}; diff --git a/database/migrations/2025_09_05_054824_create_predefined_filters_permissions.php b/database/migrations/2025_09_05_054824_create_predefined_filters_permissions.php new file mode 100644 index 000000000000..28c4b1b99186 --- /dev/null +++ b/database/migrations/2025_09_05_054824_create_predefined_filters_permissions.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('predefined_filter_id') + ->constrained('predefined_filters') + ->onDelete('cascade'); + + $table->unsignedBigInteger('permission_group_id'); + $table->unsignedInteger('created_by'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('predefined_filter_permissions'); + } +}; diff --git a/database/migrations/2025_09_25_120708_make_predefined_filter_permissions_unique.php b/database/migrations/2025_09_25_120708_make_predefined_filter_permissions_unique.php new file mode 100644 index 000000000000..116ebc00aee3 --- /dev/null +++ b/database/migrations/2025_09_25_120708_make_predefined_filter_permissions_unique.php @@ -0,0 +1,45 @@ +groupBy('predefined_filter_id', 'permission_group_id') + ->havingRaw('COUNT(*) > 1') + ->get(); + + foreach ($duplicates as $duplicate) { + $records = PredefinedFilterPermission::where('predefined_filter_id', $duplicate->predefined_filter_id) + ->where('permission_group_id', $duplicate->permission_group_id) + ->orderBy('id') // Keep the oldest one + ->get(); + + // Keep the first one, delete the rest + $records->slice(1)->each->delete(); + } + + // Step 2: Add unique constraint + Schema::table('predefined_filter_permissions', function (Blueprint $table) { + $table->unique( + ['predefined_filter_id', 'permission_group_id'], + 'unique_predefined_filter_permission' + ); + }); + } + + public function down(): void + { + Schema::table('predefined_filter_permissions', function (Blueprint $table) { + $table->dropForeign('predefined_filter_permissions_predefined_filter_id_foreign'); + $table->dropUnique('unique_predefined_filter_permission'); + }); + } +} +; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 6816df5804bf..11476e73a99f 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -39,13 +39,15 @@ public function run() $this->call(StatuslabelSeeder::class); $this->call(AccessorySeeder::class); $this->call(CustomFieldSeeder::class); + $this->call(AssetSeeder::class); $this->call(LicenseSeeder::class); $this->call(ComponentSeeder::class); $this->call(ConsumableSeeder::class); $this->call(ActionlogSeeder::class); - - + $this->call(PredefinedFilterSeeder::class); + $this->call(PredefinedFilterPermissionSeeder::class); + Artisan::call('snipeit:sync-asset-locations', ['--output' => 'all']); $output = Artisan::output(); Log::info($output); diff --git a/database/seeders/GroupMatrixSeeder.php b/database/seeders/GroupMatrixSeeder.php new file mode 100644 index 000000000000..67e9cf44376f --- /dev/null +++ b/database/seeders/GroupMatrixSeeder.php @@ -0,0 +1,71 @@ + 'assets.view', + 'CREATE' => 'assets.create', + 'EDIT' => 'assets.edit', + 'DELETE' => 'assets.delete', + + // Predefined Filters + 'PF_VIEW' => 'predefinedFilters.view', + 'PF_EDIT' => 'predefinedFilters.edit', + 'PF_DELETE' => 'predefinedFilters.delete', + + ]; + + public function run(): void + { + $defs = [ + 'grp_none_1' => [], + 'grp_none_2' => [], + 'grp_none_3' => [], + + // Assets + 'grp_view' => ['assets.view'], + 'grp_edit' => ['assets.edit'], + 'grp_delete' => ['assets.delete'], + + 'grp_view_edit' => ['assets.view','assets.edit'], + 'grp_view_edit_delete' => ['assets.view','assets.edit','assets.delete'], + 'grp_edit_delete' => ['assets.edit','assets.delete'], + 'grp_create_delete_no_view' => ['assets.create','assets.delete'], + 'grp_edit_delete_no_view' => ['assets.edit','assets.delete'], + 'grp_create_edit_no_view' => ['assets.create','assets.edit'], + 'grp_create' => ['assets.create'], + 'grp_view_delete' => ['assets.view','assets.delete'], + ]; + + foreach ($defs as $name => $keys) { + $g = PermissionGroup::firstOrCreate(['name'=>$name], ['notes'=>'test-matrix']); + $g->permissions = json_encode( + collect($keys)->mapWithKeys(fn($k)=>[$k=>'1'])->all(), + JSON_UNESCAPED_SLASHES + ); + $g->save(); + } + + foreach ([ + 'grp_pf_view' => ['predefinedFilter.view'], + 'grp_pf_edit' => ['predefinedFilter.edit'], + 'grp_pf_delete' => ['predefinedFilter.delete'], + ] as $name => $keys) { + $g = PermissionGroup::firstOrCreate(['name'=>$name], ['notes'=>'test-matrix']); + $g->permissions = json_encode( + collect($keys)->mapWithKeys(fn($k)=>[$k=>'1'])->all(), + JSON_UNESCAPED_SLASHES + ); + $g->save(); + } + } +} diff --git a/database/seeders/PredefinedFilterPermissionSeeder.php b/database/seeders/PredefinedFilterPermissionSeeder.php new file mode 100644 index 000000000000..963226337d1b --- /dev/null +++ b/database/seeders/PredefinedFilterPermissionSeeder.php @@ -0,0 +1,61 @@ +delete(); + + $userToDelete = User::where("email","predefinedfilters@permission.com")->first(); + + if ($userToDelete) { + $userToDelete->delete(); + } + + $user = User::firstOrCreate( + ['email'=> 'predefined@filter.com'], + [ + 'activated' => 1, + 'first_name' => 'Filter', + 'last_name'=> 'Permission', + 'username' => 'filterPermission', + 'email'=> 'predefinedfilters@permission.com', + 'password'=> Hash::make('1234567890'), + 'permissions' => '{"superuser":"1"}', + ]); + + if (!$user instanceof User) { + throw new Exception('user could not be created.. seeder aborting..'); + } + + $filters = PredefinedFilter::limit(3)->get(); + + try { + foreach ($filters as $filter) { + PredefinedFilterPermission::create([ + 'predefined_filter_id' => $filter->id, + 'permission_group_id' => 1, + 'created_by' => $user->id, + ]); + } + } catch (\Exception $e) { + Log::debug($e); + } + } +} diff --git a/database/seeders/PredefinedFilterSeeder.php b/database/seeders/PredefinedFilterSeeder.php new file mode 100644 index 000000000000..55169872301e --- /dev/null +++ b/database/seeders/PredefinedFilterSeeder.php @@ -0,0 +1,269 @@ +each(fn ($pf) => $pf->groups()->detach()); + } + PredefinedFilter::query()->delete(); + + $owner = User::firstOrCreate( + ['email'=> 'predefined@filter.com'], + [ + 'activated' => 1, + 'first_name' => 'Filter', + 'last_name' => 'Predefined', + 'username' => 'filter', + 'password' => Hash::make('1234567890'), + 'permissions' => '{"superuser":"1"}', + ] + ); + + $hidden = User::firstOrCreate( + ['email'=> 'hidden_predefined@filter.com'], + [ + 'activated' => 0, + 'first_name' => 'Hidden', + 'last_name' => 'Owner', + 'username' => 'hidden_owner', + 'password' => Hash::make('1234567890'), + ] + ); + + $company = Company::factory()->create(); + $model = AssetModel::factory()->create(); + $location = Location::factory()->create(); + $status = Statuslabel::factory()->create(); + $supplier = Supplier::factory()->create(); + + $filters = [ + [ + 'name' => 'Asset Tag UI Copy', + 'filter_data' => [ + [ + 'field' => 'asset_tag', + 'value' => '123', + 'operator' => 'contains', + 'logic' => 'AND', + ], + ], + ], + [ + 'name' => 'Test Name Filter', + 'filter_data' => [ + [ + 'field' => 'name', + 'value' => 'Test', + 'operator' => 'equals', + 'logic' => 'AND', + ], + ], + ], + [ + 'name' => 'Asset TAG Like 123', + 'filter_data' => [ + [ + 'field' => 'asset_tag', + 'value' => '123', + 'operator' => 'contains', + 'logic' => 'AND', + ], + ], + ], + [ + 'name' => 'Model filter', + 'filter_data' => [ + [ + 'field' => 'model', + 'value' => [$model->id], + 'operator' => 'equals', + 'logic' => 'AND', + ], + ], + ], + [ + 'name' => 'Serial filter', + 'filter_data' => [ + [ + 'field' => 'serial', + 'value' => 'FooBar', + 'operator' => 'equals', + 'logic' => 'AND', + ], + ], + ], + [ + 'name' => 'Status filter', + 'filter_data' => [ + [ + 'field' => 'status', + 'value' => [$status->id], + 'operator' => 'equals', + 'logic' => 'AND', + ], + ], + ], + [ + 'name' => 'Supplier filter', + 'filter_data' => [ + [ + 'field' => 'supplier', + 'value' => [$supplier->id], + 'operator' => 'equals', + 'logic' => 'AND', + ], + ], + ], + [ + 'name' => 'Company filter', + 'filter_data' => [ + [ + 'field' => 'company', + 'value' => [$company->id], + 'operator' => 'equals', + 'logic' => 'AND', + ], + ], + ], + [ + 'name' => 'RTD-Location filter', + 'filter_data' => [ + [ + 'field' => 'rtd_location', + 'value' => [$location->id], + 'operator' => 'equals', + 'logic' => 'AND', + ], + ], + ], + [ + 'name' => 'Custom Field Ram', + 'filter_data' => [ + [ + 'field' => '_snipeit_ram_3', + 'value' => '32', + 'operator' => 'equals', + 'logic' => 'AND', + ], + ], + ], + [ + 'name' => 'Purchased Between', + 'filter_data' => [ + [ + 'field' => 'purchase_date_start', + 'value' => '2024-10-15', + 'operator' => 'equals', + 'logic' => 'AND', + ], + [ + 'field' => 'purchase_date_end', + 'value' => '2024-10-30', + 'operator' => 'equals', + 'logic' => 'AND', + ], + ], + ], + [ + 'name' => 'Combo contains: Model AND CustomField_RAM', + 'filter_data' => [ + [ + 'field' => 'model', + 'value' => ['book'], + 'operator' => 'contains', + 'logic' => 'AND', + ], + [ + 'field' => '_snipeit_ram_3', + 'value' => '32', + 'operator' => 'equals', + 'logic' => 'AND', + ], + ], + ], + [ + 'name' => 'Combo contains: Model AND Manufacturer', + 'filter_data' => [ + [ + 'field' => 'model', + 'value' => ['book'], + 'operator' => 'contains', + 'logic' => 'AND', + ], + [ + 'field' => 'manufacturer', + 'value' => [1], + 'operator' => 'equals', + 'logic' => 'AND', + ], + ], + ], + [ + 'name' => 'Combo contains: Model NOT Manufacturer', + 'filter_data' => [ + [ + 'field' => 'model', + 'value' => ['book'], + 'operator' => 'contains', + 'logic' => 'AND', + ], + [ + 'field' => 'manufacturer', + 'value' => ['apple'], + 'operator' => 'contains', + 'logic' => 'NOT', + ], + ], + ], + + [ + 'name' => 'Assigned To Location contains name', + 'filter_data' => [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => Location::class, + 'value' => $location->name, + ], + 'operator' => 'contains', + 'logic' => 'AND', + ], + ], + ], + + [ + 'name' => 'ShouldNotBeVisibleForUserFilter', + 'created_by' => $hidden->id, + 'filter_data' => [ + [ + 'field' => 'company', + 'value' => [$company->id], + 'operator' => 'equals', + 'logic' => 'AND', + ], + ], + ], + ]; + + foreach ($filters as $f) { + PredefinedFilter::create([ + 'name' => $f['name'], + 'created_by' => $f['created_by'] ?? $owner->id, + 'filter_data' => $f['filter_data'], + ]); + } + }); + } +} diff --git a/public/css/dist/advanced-search-index.css b/public/css/dist/advanced-search-index.css new file mode 100644 index 000000000000..f832056f3083 --- /dev/null +++ b/public/css/dist/advanced-search-index.css @@ -0,0 +1,70 @@ + /* + Layout container for the whole page. + Uses flexbox so the filter and table sections can sit side by side on desktop, and stack on mobile. + */ + .responsive-layout { + display: flex; + flex-wrap: wrap; + width: 100%; + } + + /* + The filter (sidebar) section. + Transition allows smooth showing/hiding. + */ + .filter-section { + transition: all 0.3s ease; + } + + /* + When .hide is applied, the filter section is hidden. + !important ensures it's forced, even if overridden by other classes. + */ + .filter-section.hide { + display: none !important; + } + + /* ---------- DESKTOP Styles (screen ≥ 768px) ---------- */ + @media screen and (min-width: 768px) { + + /* + Filter sidebar gets 25% width, and some space on the right. + */ + .filter-section { + flex: 0 0 25%; + max-width: 25%; + padding-right: 15px; + } + + /* + Main table takes the remaining 75%. + */ + .table-section { + flex: 0 0 75%; + max-width: 75%; + } + + /* + If filter is hidden, the table takes full width. + */ + .filter-section.hide+.table-section { + flex: 0 0 100%; + max-width: 100%; + } + } + + /* ---------- MOBILE Styles (screen < 768px) ---------- */ + @media screen and (max-width: 767px) { + + /* + Filter takes full width, and sits above the table section. + */ + .filter-section { + width: 100%; + margin-bottom: 15px; + } + + .table-section { + width: 100%; + } + } \ No newline at end of file diff --git a/public/css/dist/advanced-search-index.min.css b/public/css/dist/advanced-search-index.min.css new file mode 100644 index 000000000000..633495408e52 --- /dev/null +++ b/public/css/dist/advanced-search-index.min.css @@ -0,0 +1 @@ +.responsive-layout{display:flex;flex-wrap:wrap;width:100%}.filter-section{transition:all .3s ease}.filter-section.hide{display:none!important}@media screen and (min-width:768px){.filter-section{flex:0 0 25%;max-width:25%;padding-right:15px}.table-section{flex:0 0 75%;max-width:75%}.filter-section.hide+.table-section{flex:0 0 100%;max-width:100%}}@media screen and (max-width:767px){.filter-section{width:100%;margin-bottom:15px}.table-section{width:100%}} diff --git a/public/css/dist/advanced-search.css b/public/css/dist/advanced-search.css new file mode 100644 index 000000000000..f7a2d4d87bf7 --- /dev/null +++ b/public/css/dist/advanced-search.css @@ -0,0 +1 @@ +.filter-sidebar{transition:all .3s ease;position:relative}.filter-body{transition:all .3s ease;overflow:hidden}.filter-content{transition:opacity .2s ease}.clear-text,.filter-title{transition:opacity .2s ease}@media (max-width:768px){.filter-sidebar{width:100%!important;margin-bottom:15px}}.container{width:100%;margin:0 auto;padding:10px;box-sizing:border-box}#advanced-search-filters{display:block;max-width:100%;margin:0}.advanced-search-panel-with-buffer{min-height:calc(100% + 70px);padding-bottom:70px}.box-body{overflow-y:auto;padding:15px}@media screen and (max-width:768px){.box-body{max-height:75vh}}@media screen and (min-width:769px){.box-body{height:100%}}.box-body::-webkit-scrollbar{width:6px}.collapse-toggle{margin-right:5px}.icon-desktop,.icon-mobile{display:none}@media screen and (min-width:768px){.icon-desktop{display:inline}}@media screen and (max-width:767px){.icon-mobile{display:inline}}.responsive-layout{display:flex;flex-wrap:wrap;width:100%}.filter-section{transition:all .3s ease}.filter-section.hide{display:none!important}.advanced-search-wrapper{width:100%;padding:0 15px}.advanced-search-grid-container{display:flex;flex-direction:column;gap:20px}.advanced-search-item-container{display:flex;flex-direction:column;gap:8px}.filter-field-name{font-weight:600;font-size:14px;margin:0}.filter-controls-row{display:flex;gap:0;width:100%}.filter-option{flex:0 0 60px;min-width:35px;max-width:42px;height:38px;padding:0 2px;font-size:13px;border:1px solid var(--text-main);border-top-right-radius:0;border-bottom-right-radius:0;border-right:none}.advanced-search-default-field,.advanced-search-grid-container .form-control:not(.filter-option),.select2-container{flex:1;height:38px;font-size:13px;border:1px solid var(--text-main);border-top-left-radius:0;border-bottom-left-radius:0}.advanced-search-default-field,.advanced-search-grid-container .form-control:not(.filter-option){padding:6px 10px}.select2-container{width:auto;max-width:width;box-sizing:border-box;background-color:#00008b}.select2-container--default .select2-selection--multiple,.select2-container--default .select2-selection--single{height:38px;border:1px solid var(--button-hover);border-top-left-radius:0;border-bottom-left-radius:0;border-left:none}.select2-container--default .select2-selection--single .select2-selection__rendered{line-height:36px;padding-left:10px}.select2-search--dropdown{background-color:var(--hover-link,#eee)}.select2-results{background-color:var(--button-main)}.advanced-search-default-field:focus,.filter-option:focus,.form-control:focus{border-color:var(--button-hover);outline:0}.select2-container--default .select2-selection:focus{border-color:var(--button-hover);outline:0}.input-daterange{display:flex;align-items:center;flex:1}.input-daterange .form-control{border-radius:0;border-left:none}.input-daterange .form-control:first-child{border-top-left-radius:0;border-bottom-left-radius:0}.input-daterange .form-control:last-child{border-top-right-radius:4px;border-bottom-right-radius:4px}.input-group-addon{display:flex;align-items:center;justify-content:center;padding:11px}.filter-option{-moz-appearance:none;-webkit-appearance:none;appearance:none}@media screen and (min-width:768px){.filter-section{flex:0 0 25%;max-width:25%;padding-right:15px}.table-section{flex:0 0 75%;max-width:75%}.filter-section.hide+.table-section{flex:0 0 100%;max-width:100%}}@media screen and (max-width:767px){.filter-section{width:100%;margin-bottom:15px}.table-section{width:100%}.filter-option{flex:0 0 120px;min-width:120px}}.select2-container,.select2-container .select2-selection--multiple,.select2-container .select2-selection--single{transition:height .2s ease,min-height .2s ease,max-height .2s ease}.select2-container--open .select2-selection--multiple{overflow-y:auto!important}.select2-selection--multiple::-webkit-scrollbar{width:8px}.select2-selection--multiple::-webkit-scrollbar-track{border-radius:4px}.select2-selection--multiple::-webkit-scrollbar-thumb{border-radius:4px}.responsive-layout{display:flex;flex-wrap:wrap;width:100%}.filter-section{transition:all .3s ease}.filter-section.hide{display:none!important}.advanced-search-wrapper{width:100%;padding:0 15px}.advanced-search-grid-container{display:flex;flex-direction:column;gap:20px}.advanced-search-item-container{display:flex;flex-direction:column;gap:8px}.filter-field-name{font-weight:600;font-size:14px;margin:0}.filter-controls-row{display:flex;gap:0;width:100%}.filter-option{flex:0 0 60px;min-width:35px;max-width:42px;height:38px;padding:0 2px;font-size:13px;border:1px solid #ced4da;border-top-right-radius:0;border-bottom-right-radius:0;border-right:none}.advanced-search-default-field,.advanced-search-grid-container .form-control:not(.filter-option),.select2-container{flex:1;height:38px;font-size:13px;border:1px solid #ced4da;border-top-left-radius:0;border-bottom-left-radius:0}.advanced-search-default-field,.advanced-search-grid-container .form-control:not(.filter-option){padding:6px 10px}.select2-container{min-width:0;width:100%;box-sizing:border-box}.select2-container--open{width:unset;box-sizing:border-box;z-index:100}.select2-container--default .select2-selection--multiple,.select2-container--default .select2-selection--single{height:38px;border:1px solid var(--text-main);border-top-left-radius:0;border-bottom-left-radius:0;border-left:none}.select2-container--default .select2-selection--single .select2-selection__rendered{line-height:36px;padding-left:10px}.advanced-search-default-field:focus,.filter-option:focus,.form-control:focus{border-color:var(--button-hover);outline:0}.select2-container--default .select2-selection:focus{border-color:var(--button-hover);outline:0}.input-daterange{display:flex;align-items:center;flex:1}.input-daterange .form-control{border-radius:0;border-left:none}.input-daterange .form-control:first-child{border-top-left-radius:0;border-bottom-left-radius:0}.input-daterange .form-control:last-child{border-top-right-radius:4px;border-bottom-right-radius:4px}.input-daterange .input-group-addon{background-color:var(--back-sub,#ecf0f5);color:var(--text-main)}.filter-option{-moz-appearance:none;-webkit-appearance:none;appearance:none}@media screen and (min-width:768px){.filter-section{flex:0 0 25%;max-width:25%;padding-right:15px}.table-section{flex:0 0 75%;max-width:75%}.filter-section.hide+.table-section{flex:0 0 100%;max-width:100%}}@media screen and (max-width:767px){.filter-section{width:100%;margin-bottom:15px}.table-section{width:100%}.filter-option{flex:0 0 120px;min-width:120px}}.select2-container,.select2-container .select2-selection--multiple,.select2-container .select2-selection--single{border-bottom:1.5px solid #ced4da;transition:height .2s ease,min-height .2s ease,max-height .2s ease}.select2-container--open .select2-selection--multiple{overflow-y:auto!important}.select2-selection--multiple::-webkit-scrollbar{width:8px}.select2-selection--multiple::-webkit-scrollbar-track{border-radius:4px}.select2-selection--multiple::-webkit-scrollbar-thumb{border-radius:4px}.select2-selection{border-bottom:1.5px solid #ced4da}.floating-buttons-fab-fixed-wrapper{position:fixed;bottom:22px;left:50%;transform:translateX(-50%);display:flex;gap:20px;z-index:1000;pointer-events:auto;padding:0 15px;justify-content:center;transition:left .25s ease}.floating-buttons-fab-scrollable-wrapper{position:absolute;bottom:20px;left:50%!important;transform:translateX(-50%);display:flex;gap:20px;z-index:1000;pointer-events:auto;transition:all 280ms cubic-bezier(.2,.9,.2,1);padding:0 15px;justify-content:center}.floating-buttons-inner{display:flex;gap:20px;align-items:center;justify-content:center;transition:transform .3s cubic-bezier(.2,.9,.2,1),opacity 220ms ease;will-change:transform,opacity;transform-origin:center bottom;opacity:1}.floating-buttons-fab-scrollable-wrapper .floating-buttons-inner{transform:translateY(-6px) scale(.995);opacity:.98}.floating-buttons-fab{width:50px;height:50px;border-radius:50%;background-color:var(--btn-theme-base);color:var(--text-main);border:none;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:background .22s ease,transform 160ms cubic-bezier(.2,.9,.2,1);flex-shrink:0}.floating-buttons-fab:focus,.floating-buttons-fab:hover{background-color:var(--btn-theme-hover);transform:translateY(-3px);outline:0}.floating-buttons-menu{position:absolute;bottom:70px;left:50%;transform:translateX(38px) translateY(6px);background:var(--back-main);border:1px solid var(--back-sub);border-radius:8px;display:flex;flex-direction:column;min-width:160px;z-index:1001;transition:transform .2s ease,opacity 180ms ease,visibility 180ms;opacity:0;visibility:hidden;pointer-events:none}.floating-buttons-menu.open{opacity:1;transform:translateX(38px) translateY(0);visibility:visible;pointer-events:auto;background-color:var(--callout-bg-color)}.floating-buttons-menu a{padding:10px 12px;text-decoration:none;color:var(--btn-theme-base);border-bottom:1px solid var(--text-sub);font-size:14px;background:0 0}.floating-buttons-menu a:last-child{border-bottom:none}.floating-buttons-menu a:focus,.floating-buttons-menu a:hover{background-color:var(--btn-theme-hover);color:var(--hover-link);border-radius:8px;outline:0}.floating-buttons-disabled{cursor:not-allowed;opacity:.35;pointer-events:none}@media screen and (max-width:640px){.floating-buttons-fab-fixed-wrapper{left:50%!important;bottom:18px}.floating-buttons-fab-scrollable-wrapper{left:50%!important;bottom:18px}.floating-buttons-menu{left:50%;min-width:140px;bottom:68px}} diff --git a/public/css/dist/advanced-search.min.css b/public/css/dist/advanced-search.min.css new file mode 100644 index 000000000000..f7a2d4d87bf7 --- /dev/null +++ b/public/css/dist/advanced-search.min.css @@ -0,0 +1 @@ +.filter-sidebar{transition:all .3s ease;position:relative}.filter-body{transition:all .3s ease;overflow:hidden}.filter-content{transition:opacity .2s ease}.clear-text,.filter-title{transition:opacity .2s ease}@media (max-width:768px){.filter-sidebar{width:100%!important;margin-bottom:15px}}.container{width:100%;margin:0 auto;padding:10px;box-sizing:border-box}#advanced-search-filters{display:block;max-width:100%;margin:0}.advanced-search-panel-with-buffer{min-height:calc(100% + 70px);padding-bottom:70px}.box-body{overflow-y:auto;padding:15px}@media screen and (max-width:768px){.box-body{max-height:75vh}}@media screen and (min-width:769px){.box-body{height:100%}}.box-body::-webkit-scrollbar{width:6px}.collapse-toggle{margin-right:5px}.icon-desktop,.icon-mobile{display:none}@media screen and (min-width:768px){.icon-desktop{display:inline}}@media screen and (max-width:767px){.icon-mobile{display:inline}}.responsive-layout{display:flex;flex-wrap:wrap;width:100%}.filter-section{transition:all .3s ease}.filter-section.hide{display:none!important}.advanced-search-wrapper{width:100%;padding:0 15px}.advanced-search-grid-container{display:flex;flex-direction:column;gap:20px}.advanced-search-item-container{display:flex;flex-direction:column;gap:8px}.filter-field-name{font-weight:600;font-size:14px;margin:0}.filter-controls-row{display:flex;gap:0;width:100%}.filter-option{flex:0 0 60px;min-width:35px;max-width:42px;height:38px;padding:0 2px;font-size:13px;border:1px solid var(--text-main);border-top-right-radius:0;border-bottom-right-radius:0;border-right:none}.advanced-search-default-field,.advanced-search-grid-container .form-control:not(.filter-option),.select2-container{flex:1;height:38px;font-size:13px;border:1px solid var(--text-main);border-top-left-radius:0;border-bottom-left-radius:0}.advanced-search-default-field,.advanced-search-grid-container .form-control:not(.filter-option){padding:6px 10px}.select2-container{width:auto;max-width:width;box-sizing:border-box;background-color:#00008b}.select2-container--default .select2-selection--multiple,.select2-container--default .select2-selection--single{height:38px;border:1px solid var(--button-hover);border-top-left-radius:0;border-bottom-left-radius:0;border-left:none}.select2-container--default .select2-selection--single .select2-selection__rendered{line-height:36px;padding-left:10px}.select2-search--dropdown{background-color:var(--hover-link,#eee)}.select2-results{background-color:var(--button-main)}.advanced-search-default-field:focus,.filter-option:focus,.form-control:focus{border-color:var(--button-hover);outline:0}.select2-container--default .select2-selection:focus{border-color:var(--button-hover);outline:0}.input-daterange{display:flex;align-items:center;flex:1}.input-daterange .form-control{border-radius:0;border-left:none}.input-daterange .form-control:first-child{border-top-left-radius:0;border-bottom-left-radius:0}.input-daterange .form-control:last-child{border-top-right-radius:4px;border-bottom-right-radius:4px}.input-group-addon{display:flex;align-items:center;justify-content:center;padding:11px}.filter-option{-moz-appearance:none;-webkit-appearance:none;appearance:none}@media screen and (min-width:768px){.filter-section{flex:0 0 25%;max-width:25%;padding-right:15px}.table-section{flex:0 0 75%;max-width:75%}.filter-section.hide+.table-section{flex:0 0 100%;max-width:100%}}@media screen and (max-width:767px){.filter-section{width:100%;margin-bottom:15px}.table-section{width:100%}.filter-option{flex:0 0 120px;min-width:120px}}.select2-container,.select2-container .select2-selection--multiple,.select2-container .select2-selection--single{transition:height .2s ease,min-height .2s ease,max-height .2s ease}.select2-container--open .select2-selection--multiple{overflow-y:auto!important}.select2-selection--multiple::-webkit-scrollbar{width:8px}.select2-selection--multiple::-webkit-scrollbar-track{border-radius:4px}.select2-selection--multiple::-webkit-scrollbar-thumb{border-radius:4px}.responsive-layout{display:flex;flex-wrap:wrap;width:100%}.filter-section{transition:all .3s ease}.filter-section.hide{display:none!important}.advanced-search-wrapper{width:100%;padding:0 15px}.advanced-search-grid-container{display:flex;flex-direction:column;gap:20px}.advanced-search-item-container{display:flex;flex-direction:column;gap:8px}.filter-field-name{font-weight:600;font-size:14px;margin:0}.filter-controls-row{display:flex;gap:0;width:100%}.filter-option{flex:0 0 60px;min-width:35px;max-width:42px;height:38px;padding:0 2px;font-size:13px;border:1px solid #ced4da;border-top-right-radius:0;border-bottom-right-radius:0;border-right:none}.advanced-search-default-field,.advanced-search-grid-container .form-control:not(.filter-option),.select2-container{flex:1;height:38px;font-size:13px;border:1px solid #ced4da;border-top-left-radius:0;border-bottom-left-radius:0}.advanced-search-default-field,.advanced-search-grid-container .form-control:not(.filter-option){padding:6px 10px}.select2-container{min-width:0;width:100%;box-sizing:border-box}.select2-container--open{width:unset;box-sizing:border-box;z-index:100}.select2-container--default .select2-selection--multiple,.select2-container--default .select2-selection--single{height:38px;border:1px solid var(--text-main);border-top-left-radius:0;border-bottom-left-radius:0;border-left:none}.select2-container--default .select2-selection--single .select2-selection__rendered{line-height:36px;padding-left:10px}.advanced-search-default-field:focus,.filter-option:focus,.form-control:focus{border-color:var(--button-hover);outline:0}.select2-container--default .select2-selection:focus{border-color:var(--button-hover);outline:0}.input-daterange{display:flex;align-items:center;flex:1}.input-daterange .form-control{border-radius:0;border-left:none}.input-daterange .form-control:first-child{border-top-left-radius:0;border-bottom-left-radius:0}.input-daterange .form-control:last-child{border-top-right-radius:4px;border-bottom-right-radius:4px}.input-daterange .input-group-addon{background-color:var(--back-sub,#ecf0f5);color:var(--text-main)}.filter-option{-moz-appearance:none;-webkit-appearance:none;appearance:none}@media screen and (min-width:768px){.filter-section{flex:0 0 25%;max-width:25%;padding-right:15px}.table-section{flex:0 0 75%;max-width:75%}.filter-section.hide+.table-section{flex:0 0 100%;max-width:100%}}@media screen and (max-width:767px){.filter-section{width:100%;margin-bottom:15px}.table-section{width:100%}.filter-option{flex:0 0 120px;min-width:120px}}.select2-container,.select2-container .select2-selection--multiple,.select2-container .select2-selection--single{border-bottom:1.5px solid #ced4da;transition:height .2s ease,min-height .2s ease,max-height .2s ease}.select2-container--open .select2-selection--multiple{overflow-y:auto!important}.select2-selection--multiple::-webkit-scrollbar{width:8px}.select2-selection--multiple::-webkit-scrollbar-track{border-radius:4px}.select2-selection--multiple::-webkit-scrollbar-thumb{border-radius:4px}.select2-selection{border-bottom:1.5px solid #ced4da}.floating-buttons-fab-fixed-wrapper{position:fixed;bottom:22px;left:50%;transform:translateX(-50%);display:flex;gap:20px;z-index:1000;pointer-events:auto;padding:0 15px;justify-content:center;transition:left .25s ease}.floating-buttons-fab-scrollable-wrapper{position:absolute;bottom:20px;left:50%!important;transform:translateX(-50%);display:flex;gap:20px;z-index:1000;pointer-events:auto;transition:all 280ms cubic-bezier(.2,.9,.2,1);padding:0 15px;justify-content:center}.floating-buttons-inner{display:flex;gap:20px;align-items:center;justify-content:center;transition:transform .3s cubic-bezier(.2,.9,.2,1),opacity 220ms ease;will-change:transform,opacity;transform-origin:center bottom;opacity:1}.floating-buttons-fab-scrollable-wrapper .floating-buttons-inner{transform:translateY(-6px) scale(.995);opacity:.98}.floating-buttons-fab{width:50px;height:50px;border-radius:50%;background-color:var(--btn-theme-base);color:var(--text-main);border:none;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:background .22s ease,transform 160ms cubic-bezier(.2,.9,.2,1);flex-shrink:0}.floating-buttons-fab:focus,.floating-buttons-fab:hover{background-color:var(--btn-theme-hover);transform:translateY(-3px);outline:0}.floating-buttons-menu{position:absolute;bottom:70px;left:50%;transform:translateX(38px) translateY(6px);background:var(--back-main);border:1px solid var(--back-sub);border-radius:8px;display:flex;flex-direction:column;min-width:160px;z-index:1001;transition:transform .2s ease,opacity 180ms ease,visibility 180ms;opacity:0;visibility:hidden;pointer-events:none}.floating-buttons-menu.open{opacity:1;transform:translateX(38px) translateY(0);visibility:visible;pointer-events:auto;background-color:var(--callout-bg-color)}.floating-buttons-menu a{padding:10px 12px;text-decoration:none;color:var(--btn-theme-base);border-bottom:1px solid var(--text-sub);font-size:14px;background:0 0}.floating-buttons-menu a:last-child{border-bottom:none}.floating-buttons-menu a:focus,.floating-buttons-menu a:hover{background-color:var(--btn-theme-hover);color:var(--hover-link);border-radius:8px;outline:0}.floating-buttons-disabled{cursor:not-allowed;opacity:.35;pointer-events:none}@media screen and (max-width:640px){.floating-buttons-fab-fixed-wrapper{left:50%!important;bottom:18px}.floating-buttons-fab-scrollable-wrapper{left:50%!important;bottom:18px}.floating-buttons-menu{left:50%;min-width:140px;bottom:68px}} diff --git a/public/css/dist/modal.css b/public/css/dist/modal.css new file mode 100644 index 000000000000..4b8ecf800812 --- /dev/null +++ b/public/css/dist/modal.css @@ -0,0 +1,40 @@ + .modal-radio { + margin-bottom: 1.5vh; + } + + .radio-label-text { + margin-left: 0.5em; + display: inline-block; + } + + .capitalize-first-letter { + text-transform: capitalize; + } + + .sr-only { + position: absolute !important; + width: 1px !important; + height: 1px !important; + margin: -1px !important; + padding: 0 !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + border: 0 !important; + } + + #advanced-search-modal-container { + z-index: 100; + } + + + @media only screen and (max-width: 768px) { + #advanced-search-modal-container .modal-dialog { + padding-top: 25vh; + } + } + + @media only screen and (min-width: 768px) { + #advanced-search-modal-container .modal-dialog { + padding-top: 20vh; + } + } \ No newline at end of file diff --git a/public/css/dist/modal.min.css b/public/css/dist/modal.min.css new file mode 100644 index 000000000000..9a04b2e8801c --- /dev/null +++ b/public/css/dist/modal.min.css @@ -0,0 +1 @@ +.modal-radio{margin-bottom:1.5vh}.radio-label-text{margin-left:.5em;display:inline-block}.capitalize-first-letter{text-transform:capitalize}.sr-only{position:absolute!important;width:1px!important;height:1px!important;margin:-1px!important;padding:0!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;border:0!important}#advanced-search-modal-container{z-index:100}@media only screen and (max-width:768px){#advanced-search-modal-container .modal-dialog{padding-top:25vh}}@media only screen and (min-width:768px){#advanced-search-modal-container .modal-dialog{padding-top:20vh}} diff --git a/public/js/dist/advanced-search-index.js b/public/js/dist/advanced-search-index.js new file mode 100644 index 000000000000..5ceb0e469dda --- /dev/null +++ b/public/js/dist/advanced-search-index.js @@ -0,0 +1 @@ +import{container}from"/js/dist/simpleDIContainer.min.js";function initFilterSidebar(){var e=document.getElementById("toggleFilterBtn"),t=document.getElementById("closeSidebarButton"),n=document.getElementById("filterSection"),i=document.querySelector(".table-section"),r=container.resolve("floatingButtons");function o(t){var i;n.classList.toggle("hide",!t),e.setAttribute("aria-expanded",t),function(t){var n=e.querySelector(".filter-btn-text"),i=container.resolve("advancedSearchTranslations");n.innerText=t?i.general_close_filters:i.general_open_filters,t?(r.show(),r.align()):r.hide()}(t),t?(!function(e){requestAnimationFrame((function(){var t=e.querySelector('button, [href], input, select, textarea, [tabindex="0"]');null==t||t.focus()}))}(n),null==(i=l(n))||i.addEventListener("keydown",a)):(!function(){var e=l(n);null==e||e.removeEventListener("keydown",a)}(),e.focus())}function a(e){if("Tab"===e.key&&!e.shiftKey){e.preventDefault();var t=null==i?void 0:i.querySelector('button, [href], input, select, textarea, [tabindex="0"]');null==t||t.focus()}}function l(e){var t=e.querySelectorAll('button, [href], input, select, textarea, [tabindex="0"]');return t[t.length-1]}e&&n&&(e.addEventListener("click",(function(){o(n.classList.contains("hide"))})),null==t||t.addEventListener("click",(function(){return o(!1)})),document.addEventListener("keydown",(function(e){"Escape"!==e.key||n.classList.contains("hide")||o(!1)})))}document.addEventListener("DOMContentLoaded",initFilterSidebar); diff --git a/public/js/dist/advanced-search-index.min.js b/public/js/dist/advanced-search-index.min.js new file mode 100644 index 000000000000..5ceb0e469dda --- /dev/null +++ b/public/js/dist/advanced-search-index.min.js @@ -0,0 +1 @@ +import{container}from"/js/dist/simpleDIContainer.min.js";function initFilterSidebar(){var e=document.getElementById("toggleFilterBtn"),t=document.getElementById("closeSidebarButton"),n=document.getElementById("filterSection"),i=document.querySelector(".table-section"),r=container.resolve("floatingButtons");function o(t){var i;n.classList.toggle("hide",!t),e.setAttribute("aria-expanded",t),function(t){var n=e.querySelector(".filter-btn-text"),i=container.resolve("advancedSearchTranslations");n.innerText=t?i.general_close_filters:i.general_open_filters,t?(r.show(),r.align()):r.hide()}(t),t?(!function(e){requestAnimationFrame((function(){var t=e.querySelector('button, [href], input, select, textarea, [tabindex="0"]');null==t||t.focus()}))}(n),null==(i=l(n))||i.addEventListener("keydown",a)):(!function(){var e=l(n);null==e||e.removeEventListener("keydown",a)}(),e.focus())}function a(e){if("Tab"===e.key&&!e.shiftKey){e.preventDefault();var t=null==i?void 0:i.querySelector('button, [href], input, select, textarea, [tabindex="0"]');null==t||t.focus()}}function l(e){var t=e.querySelectorAll('button, [href], input, select, textarea, [tabindex="0"]');return t[t.length-1]}e&&n&&(e.addEventListener("click",(function(){o(n.classList.contains("hide"))})),null==t||t.addEventListener("click",(function(){return o(!1)})),document.addEventListener("keydown",(function(e){"Escape"!==e.key||n.classList.contains("hide")||o(!1)})))}document.addEventListener("DOMContentLoaded",initFilterSidebar); diff --git a/public/js/dist/advanced-search.js b/public/js/dist/advanced-search.js new file mode 100644 index 000000000000..77861a4885fa --- /dev/null +++ b/public/js/dist/advanced-search.js @@ -0,0 +1 @@ +function _typeof(e){return _typeof="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},_typeof(e)}function _regeneratorRuntime(){"use strict";var e=_regenerator(),r=e.m(_regeneratorRuntime),t=(Object.getPrototypeOf?Object.getPrototypeOf(r):r.__proto__).constructor;function n(e){var r="function"==typeof e&&e.constructor;return!!r&&(r===t||"GeneratorFunction"===(r.displayName||r.name))}var o={throw:1,return:2,break:3,continue:3};function i(e){var r,t;return function(n){r||(r={stop:function(){return t(n.a,2)},catch:function(){return n.v},abrupt:function(e,r){return t(n.a,o[e],r)},delegateYield:function(e,o,i){return r.resultName=o,t(n.d,_regeneratorValues(e),i)},finish:function(e){return t(n.f,e)}},t=function(e,t,o){n.p=r.prev,n.n=r.next;try{return e(t,o)}finally{r.next=n.n}}),r.resultName&&(r[r.resultName]=n.v,r.resultName=void 0),r.sent=n.v,r.next=n.n;try{return e.call(this,r)}finally{n.p=r.prev,n.n=r.next}}}return(_regeneratorRuntime=function(){return{wrap:function(r,t,n,o){return e.w(i(r),t,n,o&&o.reverse())},isGeneratorFunction:n,mark:e.m,awrap:function(e,r){return new _OverloadYield(e,r)},AsyncIterator:_regeneratorAsyncIterator,async:function(e,r,t,o,a){return(n(r)?_regeneratorAsyncGen:_regeneratorAsync)(i(e),r,t,o,a)},keys:_regeneratorKeys,values:_regeneratorValues}})()}function _regeneratorValues(e){if(null!=e){var r=e["function"==typeof Symbol&&Symbol.iterator||"@@iterator"],t=0;if(r)return r.call(e);if("function"==typeof e.next)return e;if(!isNaN(e.length))return{next:function(){return e&&t>=e.length&&(e=void 0),{value:e&&e[t++],done:!e}}}}throw new TypeError(_typeof(e)+" is not iterable")}function _regeneratorKeys(e){var r=Object(e),t=[];for(var n in r)t.unshift(n);return function e(){for(;t.length;)if((n=t.pop())in r)return e.value=n,e.done=!1,e;return e.done=!0,e}}function _regeneratorAsync(e,r,t,n,o){var i=_regeneratorAsyncGen(e,r,t,n,o);return i.next().then((function(e){return e.done?e.value:i.next()}))}function _regeneratorAsyncGen(e,r,t,n,o){return new _regeneratorAsyncIterator(_regenerator().w(e,r,t,n),o||Promise)}function _regeneratorAsyncIterator(e,r){function t(n,o,i,a){try{var u=e[n](o),c=u.value;return c instanceof _OverloadYield?r.resolve(c.v).then((function(e){t("next",e,i,a)}),(function(e){t("throw",e,i,a)})):r.resolve(c).then((function(e){u.value=e,i(u)}),(function(e){return t("throw",e,i,a)}))}catch(e){a(e)}}var n;this.next||(_regeneratorDefine2(_regeneratorAsyncIterator.prototype),_regeneratorDefine2(_regeneratorAsyncIterator.prototype,"function"==typeof Symbol&&Symbol.asyncIterator||"@asyncIterator",(function(){return this}))),_regeneratorDefine2(this,"_invoke",(function(e,o,i){function a(){return new r((function(r,n){t(e,i,r,n)}))}return n=n?n.then(a,a):a()}),!0)}function _regenerator(){var e,r,t="function"==typeof Symbol?Symbol:{},n=t.iterator||"@@iterator",o=t.toStringTag||"@@toStringTag";function i(t,n,o,i){var c=n&&n.prototype instanceof u?n:u,f=Object.create(c.prototype);return _regeneratorDefine2(f,"_invoke",function(t,n,o){var i,u,c,f=0,l=o||[],s=!1,p={p:0,n:0,v:e,a:y,f:y.bind(e,4),d:function(r,t){return i=r,u=0,c=e,p.n=t,a}};function y(t,n){for(u=t,c=n,r=0;!s&&f&&!o&&r3?(o=d===n)&&(c=i[(u=i[4])?5:(u=3,3)],i[4]=i[5]=e):i[0]<=y&&((o=t<2&&yn||n>d)&&(i[4]=t,i[5]=n,p.n=d,u=0))}if(o||t>1)return a;throw s=!0,n}return function(o,l,d){if(f>1)throw TypeError("Generator is already running");for(s&&1===l&&y(l,d),u=l,c=d;(r=u<2?e:c)||!s;){i||(u?u<3?(u>1&&(p.n=-1),y(u,c)):p.n=c:p.v=c);try{if(f=2,i){if(u||(o="next"),r=i[o]){if(!(r=r.call(i,c)))throw TypeError("iterator result is not an object");if(!r.done)return r;c=r.value,u<2&&(u=0)}else 1===u&&(r=i.return)&&r.call(i),u<2&&(c=TypeError("The iterator does not provide a '"+o+"' method"),u=1);i=e}else if((r=(s=p.n<0)?c:t.call(n,p))!==a)break}catch(r){i=e,u=1,c=r}finally{f=1}}return{value:r,done:s}}}(t,o,i),!0),f}var a={};function u(){}function c(){}function f(){}r=Object.getPrototypeOf;var l=[][n]?r(r([][n]())):(_regeneratorDefine2(r={},n,(function(){return this})),r),s=f.prototype=u.prototype=Object.create(l);function p(e){return Object.setPrototypeOf?Object.setPrototypeOf(e,f):(e.__proto__=f,_regeneratorDefine2(e,o,"GeneratorFunction")),e.prototype=Object.create(s),e}return c.prototype=f,_regeneratorDefine2(s,"constructor",f),_regeneratorDefine2(f,"constructor",c),c.displayName="GeneratorFunction",_regeneratorDefine2(f,o,"GeneratorFunction"),_regeneratorDefine2(s),_regeneratorDefine2(s,o,"Generator"),_regeneratorDefine2(s,n,(function(){return this})),_regeneratorDefine2(s,"toString",(function(){return"[object Generator]"})),(_regenerator=function(){return{w:i,m:p}})()}function _regeneratorDefine2(e,r,t,n){var o=Object.defineProperty;try{o({},"",{})}catch(e){o=0}_regeneratorDefine2=function(e,r,t,n){function i(r,t){_regeneratorDefine2(e,r,(function(e){return this._invoke(r,t,e)}))}r?o?o(e,r,{value:t,enumerable:!n,configurable:!n,writable:!n}):e[r]=t:(i("next",0),i("throw",1),i("return",2))},_regeneratorDefine2(e,r,t,n)}function _OverloadYield(e,r){this.v=e,this.k=r}function asyncGeneratorStep(e,r,t,n,o,i,a){try{var u=e[i](a),c=u.value}catch(e){return void t(e)}u.done?r(c):Promise.resolve(c).then(n,o)}function _asyncToGenerator(e){return function(){var r=this,t=arguments;return new Promise((function(n,o){var i=e.apply(r,t);function a(e){asyncGeneratorStep(i,n,o,a,u,"next",e)}function u(e){asyncGeneratorStep(i,n,o,a,u,"throw",e)}a(void 0)}))}}import ApiService from"/js/dist/apiService.min.js";import FilterFormManager from"/js/dist/filterFormManager.min.js";import FilterUIController from"/js/dist/filterUiController.min.js";import FloatingButtons from"/js/dist/floating-buttons.min.js";import{container}from"/js/dist/simpleDIContainer.min.js";export default function initAdvancedSearch(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};container.register("apiService",new ApiService),container.register("filterFormManager",new FilterFormManager),container.register("floatingButtons",new FloatingButtons),document.addEventListener("livewire:init",_asyncToGenerator(_regeneratorRuntime().mark((function r(){var t,n,o,i,a,u;return _regeneratorRuntime().wrap((function(r){for(;;)switch(r.prev=r.next){case 0:return t=e.tableId,n=$("#"+t),o=new FilterUIController(n),r.next=5,o.init();case 5:container.register("filterUiController",o),o.bindEvents(),e.predefinedFilterId?(o.updateFilterWithPredefined(null,e.predefinedFilterId),i=new Option(String(e.predefinedFilterName||""),e.predefinedFilterId,!0,!0),(a=document.getElementById("predefinedfilters-select"))&&a.append(i),(u=document.getElementById("filterSection"))&&u.classList.remove("hide")):setTimeout(_asyncToGenerator(_regeneratorRuntime().mark((function e(){var r;return _regeneratorRuntime().wrap((function(e){for(;;)switch(e.prev=e.next){case 0:return(r=document.getElementById("filterSection"))&&r.classList.remove("hide"),container.resolve("filterFormManager").clearAll(),e.next=5,new Promise((function(e){return setTimeout(e,0)}));case 5:r&&r.classList.add("hide");case 6:case"end":return e.stop()}}),e)}))),0);case 8:case"end":return r.stop()}}),r)})))),document.addEventListener("unload",(function(){container.resolve("filterUiController").unbindEvents()}));var r=document.getElementById("filterSearch");r&&r.addEventListener("input",(function(e){var r=e.target.value.toLowerCase();document.querySelectorAll(".filter-item").forEach((function(e){var t=e.querySelector("label"),n=t?t.textContent.toLowerCase():"";e.style.display=n.includes(r)?"":"none"}))}))} diff --git a/public/js/dist/advanced-search.min.js b/public/js/dist/advanced-search.min.js new file mode 100644 index 000000000000..77861a4885fa --- /dev/null +++ b/public/js/dist/advanced-search.min.js @@ -0,0 +1 @@ +function _typeof(e){return _typeof="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},_typeof(e)}function _regeneratorRuntime(){"use strict";var e=_regenerator(),r=e.m(_regeneratorRuntime),t=(Object.getPrototypeOf?Object.getPrototypeOf(r):r.__proto__).constructor;function n(e){var r="function"==typeof e&&e.constructor;return!!r&&(r===t||"GeneratorFunction"===(r.displayName||r.name))}var o={throw:1,return:2,break:3,continue:3};function i(e){var r,t;return function(n){r||(r={stop:function(){return t(n.a,2)},catch:function(){return n.v},abrupt:function(e,r){return t(n.a,o[e],r)},delegateYield:function(e,o,i){return r.resultName=o,t(n.d,_regeneratorValues(e),i)},finish:function(e){return t(n.f,e)}},t=function(e,t,o){n.p=r.prev,n.n=r.next;try{return e(t,o)}finally{r.next=n.n}}),r.resultName&&(r[r.resultName]=n.v,r.resultName=void 0),r.sent=n.v,r.next=n.n;try{return e.call(this,r)}finally{n.p=r.prev,n.n=r.next}}}return(_regeneratorRuntime=function(){return{wrap:function(r,t,n,o){return e.w(i(r),t,n,o&&o.reverse())},isGeneratorFunction:n,mark:e.m,awrap:function(e,r){return new _OverloadYield(e,r)},AsyncIterator:_regeneratorAsyncIterator,async:function(e,r,t,o,a){return(n(r)?_regeneratorAsyncGen:_regeneratorAsync)(i(e),r,t,o,a)},keys:_regeneratorKeys,values:_regeneratorValues}})()}function _regeneratorValues(e){if(null!=e){var r=e["function"==typeof Symbol&&Symbol.iterator||"@@iterator"],t=0;if(r)return r.call(e);if("function"==typeof e.next)return e;if(!isNaN(e.length))return{next:function(){return e&&t>=e.length&&(e=void 0),{value:e&&e[t++],done:!e}}}}throw new TypeError(_typeof(e)+" is not iterable")}function _regeneratorKeys(e){var r=Object(e),t=[];for(var n in r)t.unshift(n);return function e(){for(;t.length;)if((n=t.pop())in r)return e.value=n,e.done=!1,e;return e.done=!0,e}}function _regeneratorAsync(e,r,t,n,o){var i=_regeneratorAsyncGen(e,r,t,n,o);return i.next().then((function(e){return e.done?e.value:i.next()}))}function _regeneratorAsyncGen(e,r,t,n,o){return new _regeneratorAsyncIterator(_regenerator().w(e,r,t,n),o||Promise)}function _regeneratorAsyncIterator(e,r){function t(n,o,i,a){try{var u=e[n](o),c=u.value;return c instanceof _OverloadYield?r.resolve(c.v).then((function(e){t("next",e,i,a)}),(function(e){t("throw",e,i,a)})):r.resolve(c).then((function(e){u.value=e,i(u)}),(function(e){return t("throw",e,i,a)}))}catch(e){a(e)}}var n;this.next||(_regeneratorDefine2(_regeneratorAsyncIterator.prototype),_regeneratorDefine2(_regeneratorAsyncIterator.prototype,"function"==typeof Symbol&&Symbol.asyncIterator||"@asyncIterator",(function(){return this}))),_regeneratorDefine2(this,"_invoke",(function(e,o,i){function a(){return new r((function(r,n){t(e,i,r,n)}))}return n=n?n.then(a,a):a()}),!0)}function _regenerator(){var e,r,t="function"==typeof Symbol?Symbol:{},n=t.iterator||"@@iterator",o=t.toStringTag||"@@toStringTag";function i(t,n,o,i){var c=n&&n.prototype instanceof u?n:u,f=Object.create(c.prototype);return _regeneratorDefine2(f,"_invoke",function(t,n,o){var i,u,c,f=0,l=o||[],s=!1,p={p:0,n:0,v:e,a:y,f:y.bind(e,4),d:function(r,t){return i=r,u=0,c=e,p.n=t,a}};function y(t,n){for(u=t,c=n,r=0;!s&&f&&!o&&r3?(o=d===n)&&(c=i[(u=i[4])?5:(u=3,3)],i[4]=i[5]=e):i[0]<=y&&((o=t<2&&yn||n>d)&&(i[4]=t,i[5]=n,p.n=d,u=0))}if(o||t>1)return a;throw s=!0,n}return function(o,l,d){if(f>1)throw TypeError("Generator is already running");for(s&&1===l&&y(l,d),u=l,c=d;(r=u<2?e:c)||!s;){i||(u?u<3?(u>1&&(p.n=-1),y(u,c)):p.n=c:p.v=c);try{if(f=2,i){if(u||(o="next"),r=i[o]){if(!(r=r.call(i,c)))throw TypeError("iterator result is not an object");if(!r.done)return r;c=r.value,u<2&&(u=0)}else 1===u&&(r=i.return)&&r.call(i),u<2&&(c=TypeError("The iterator does not provide a '"+o+"' method"),u=1);i=e}else if((r=(s=p.n<0)?c:t.call(n,p))!==a)break}catch(r){i=e,u=1,c=r}finally{f=1}}return{value:r,done:s}}}(t,o,i),!0),f}var a={};function u(){}function c(){}function f(){}r=Object.getPrototypeOf;var l=[][n]?r(r([][n]())):(_regeneratorDefine2(r={},n,(function(){return this})),r),s=f.prototype=u.prototype=Object.create(l);function p(e){return Object.setPrototypeOf?Object.setPrototypeOf(e,f):(e.__proto__=f,_regeneratorDefine2(e,o,"GeneratorFunction")),e.prototype=Object.create(s),e}return c.prototype=f,_regeneratorDefine2(s,"constructor",f),_regeneratorDefine2(f,"constructor",c),c.displayName="GeneratorFunction",_regeneratorDefine2(f,o,"GeneratorFunction"),_regeneratorDefine2(s),_regeneratorDefine2(s,o,"Generator"),_regeneratorDefine2(s,n,(function(){return this})),_regeneratorDefine2(s,"toString",(function(){return"[object Generator]"})),(_regenerator=function(){return{w:i,m:p}})()}function _regeneratorDefine2(e,r,t,n){var o=Object.defineProperty;try{o({},"",{})}catch(e){o=0}_regeneratorDefine2=function(e,r,t,n){function i(r,t){_regeneratorDefine2(e,r,(function(e){return this._invoke(r,t,e)}))}r?o?o(e,r,{value:t,enumerable:!n,configurable:!n,writable:!n}):e[r]=t:(i("next",0),i("throw",1),i("return",2))},_regeneratorDefine2(e,r,t,n)}function _OverloadYield(e,r){this.v=e,this.k=r}function asyncGeneratorStep(e,r,t,n,o,i,a){try{var u=e[i](a),c=u.value}catch(e){return void t(e)}u.done?r(c):Promise.resolve(c).then(n,o)}function _asyncToGenerator(e){return function(){var r=this,t=arguments;return new Promise((function(n,o){var i=e.apply(r,t);function a(e){asyncGeneratorStep(i,n,o,a,u,"next",e)}function u(e){asyncGeneratorStep(i,n,o,a,u,"throw",e)}a(void 0)}))}}import ApiService from"/js/dist/apiService.min.js";import FilterFormManager from"/js/dist/filterFormManager.min.js";import FilterUIController from"/js/dist/filterUiController.min.js";import FloatingButtons from"/js/dist/floating-buttons.min.js";import{container}from"/js/dist/simpleDIContainer.min.js";export default function initAdvancedSearch(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};container.register("apiService",new ApiService),container.register("filterFormManager",new FilterFormManager),container.register("floatingButtons",new FloatingButtons),document.addEventListener("livewire:init",_asyncToGenerator(_regeneratorRuntime().mark((function r(){var t,n,o,i,a,u;return _regeneratorRuntime().wrap((function(r){for(;;)switch(r.prev=r.next){case 0:return t=e.tableId,n=$("#"+t),o=new FilterUIController(n),r.next=5,o.init();case 5:container.register("filterUiController",o),o.bindEvents(),e.predefinedFilterId?(o.updateFilterWithPredefined(null,e.predefinedFilterId),i=new Option(String(e.predefinedFilterName||""),e.predefinedFilterId,!0,!0),(a=document.getElementById("predefinedfilters-select"))&&a.append(i),(u=document.getElementById("filterSection"))&&u.classList.remove("hide")):setTimeout(_asyncToGenerator(_regeneratorRuntime().mark((function e(){var r;return _regeneratorRuntime().wrap((function(e){for(;;)switch(e.prev=e.next){case 0:return(r=document.getElementById("filterSection"))&&r.classList.remove("hide"),container.resolve("filterFormManager").clearAll(),e.next=5,new Promise((function(e){return setTimeout(e,0)}));case 5:r&&r.classList.add("hide");case 6:case"end":return e.stop()}}),e)}))),0);case 8:case"end":return r.stop()}}),r)})))),document.addEventListener("unload",(function(){container.resolve("filterUiController").unbindEvents()}));var r=document.getElementById("filterSearch");r&&r.addEventListener("input",(function(e){var r=e.target.value.toLowerCase();document.querySelectorAll(".filter-item").forEach((function(e){var t=e.querySelector("label"),n=t?t.textContent.toLowerCase():"";e.style.display=n.includes(r)?"":"none"}))}))} diff --git a/public/js/dist/apiService.js b/public/js/dist/apiService.js new file mode 100644 index 000000000000..f29119a41280 --- /dev/null +++ b/public/js/dist/apiService.js @@ -0,0 +1,66 @@ +export default class ApiService { + constructor(baseUrl = '/api/v1') { + this.baseUrl = baseUrl; + this.csrfToken = document.querySelector('meta[name="csrf-token"]')?.content; + } + + fetchItemFromBackendById(type, id) { + const typeMap = { + asset: "hardware", + category: "categories", + company: "companies", + location: "locations", + manufacturer: "manufacturers", + model: "models", + groups: "groups", + group_select: "predefinedFilters", + rtd_location: "locations", + status_label: "statuslabels", + supplier: "suppliers", + user: "users" + }; + + if (!Object.prototype.hasOwnProperty.call(typeMap, type)) { + return Promise.reject(`Invalid type ${type}`); + } + + if (!Number.isInteger(id) || id <= 0) { + return Promise.reject(new Error(`Invalid id ${id}. Must be a positive integer.`)); + } + + const safeType = String(type); + const path = `${this.baseUrl}/${safeType}/${id}`; + return this.fetchFromBackend('GET', path); + } + + predefinedFilterRequest(method, filterId = null, filterData = null) { + let path = "${this.baseUrl}/predefinedFilters"; + + if (filterId !== null) { + path += "/" + filterId; + } + + return this.fetchFromBackend(method, path, filterData); + } + + fetchPredefinedFilterData(filterId) { + const path = `${this.baseUrl}/predefinedFilters/${filterId}`; + return this.fetchFromBackend('GET', path); + } + + fetchFromBackend(method, path, body = null) { + const options = { + method: method, + headers: { + accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRF-TOKEN': this.csrfToken, + 'Content-Type': 'application/json' + }, + ...(body && { body }) + }; + + return fetch(path, options); + } + +} \ No newline at end of file diff --git a/public/js/dist/apiService.min.js b/public/js/dist/apiService.min.js new file mode 100644 index 000000000000..40982576ad49 --- /dev/null +++ b/public/js/dist/apiService.min.js @@ -0,0 +1 @@ +export default class ApiService{constructor(e="/api/v1"){this.baseUrl=e,this.csrfToken=document.querySelector('meta[name="csrf-token"]')?.content}fetchItemFromBackendById(e,t){if(!Object.prototype.hasOwnProperty.call({asset:"hardware",category:"categories",company:"companies",location:"locations",manufacturer:"manufacturers",model:"models",groups:"groups",group_select:"predefinedFilters",rtd_location:"locations",status_label:"statuslabels",supplier:"suppliers",user:"users"},e))return Promise.reject(`Invalid type ${e}`);if(!Number.isInteger(t)||t<=0)return Promise.reject(new Error(`Invalid id ${t}. Must be a positive integer.`));const r=String(e),s=`${this.baseUrl}/${r}/${t}`;return this.fetchFromBackend("GET",s)}predefinedFilterRequest(e,t=null,r=null){let s="${this.baseUrl}/predefinedFilters";return null!==t&&(s+="/"+t),this.fetchFromBackend(e,s,r)}fetchPredefinedFilterData(e){const t=`${this.baseUrl}/predefinedFilters/${e}`;return this.fetchFromBackend("GET",t)}fetchFromBackend(e,t,r=null){const s={method:e,headers:{accept:"application/json","X-Requested-With":"XMLHttpRequest","X-CSRF-TOKEN":this.csrfToken,"Content-Type":"application/json"},...r&&{body:r}};return fetch(t,s)}} diff --git a/public/js/dist/filterFormManager.js b/public/js/dist/filterFormManager.js new file mode 100644 index 000000000000..7641050fb8ab --- /dev/null +++ b/public/js/dist/filterFormManager.js @@ -0,0 +1,121 @@ +import { + SelectFilterInput, + AssignedEntityFilterInput, + DateFilterInput, + TextFilterInput +} from '/js/dist/filterInputs.min.js'; +import { container } from '/js/dist/simpleDIContainer.min.js'; + +export default class FilterFormManager { + constructor() { + this.filters = []; + this.inputs = []; + this.apiService = container.resolve("apiService"); + } + + async collectFilterInputs() { + this.inputs = []; + + const tasks = []; + + // Select2 + document.querySelectorAll('select[id^="advancedSearch_"]:not(.no-select2)').forEach(el => { + tasks.push(new Promise(resolve => { + setTimeout(() => { + this.inputs.push(new SelectFilterInput(el, this.apiService)); + resolve(); + }, 0); + })); + }); + + // Dates + document.querySelectorAll( + '.input-daterange.input-group.date-range-input' + ).forEach(el => { + this.inputs.push(new DateFilterInput(el, this.apiService)); + }); + + // Text + document.querySelectorAll('input[id^="advancedSearch_"][type="text"]').forEach(el => { + tasks.push(new Promise(resolve => { + queueMicrotask(() => { + + // Skip daterangefields + if (el.classList.contains("input-daterange-field")) { + return resolve(); + } + + // AssignedTo / CheckedOutTo-fields + if (el.classList.contains("advancedSearch_polymorphicItemFormatter")) { + this.inputs.push(new AssignedEntityFilterInput(el, this.apiService)); + return resolve(); + } + + this.inputs.push(new TextFilterInput(el, this.apiService)); + resolve(); + }); + + })); + }); + await Promise.all(tasks); + return this.inputs; + } + + collectFilterData() { + this.filters = []; + + // Process all inputs polymorphically + this.inputs.forEach(input => { + input.appendTo(this.filters); + }); + + return this.filters; + } + + clearAll() { + //this.collectFilterData(); + this.inputs.forEach(field => { + field.clear(); + }); + } + + setValuesFromResponse(responseArray) { + this.clearAll(); + + const promises = []; + + for (const filter of responseArray) { + const { field: key, value, logic, operator } = filter; + + const field = this.inputs.find(input => input.key === key); + if (!field) { + console.warn(`No input found for key: ${key}`); + continue; + } + + try { + const result = field.setValue(value, logic, operator); + if (result instanceof Promise) { + promises.push(result); + } + } catch (err) { + console.error(`Failed to set value for "${key}":`, err); + } + } + + return Promise.all(promises) + .then((results) => { + this.setAdvancedSearchPanelFilterEnabledState(false); + return results; + }); + } + + setAdvancedSearchPanelFilterEnabledState(state) { + queueMicrotask(() => { + const fields = document.getElementById("advancedSearchPanel").getElementsByTagName('*'); + for (const field of fields) { + field.disabled = state; + } + }); + } +} \ No newline at end of file diff --git a/public/js/dist/filterFormManager.min.js b/public/js/dist/filterFormManager.min.js new file mode 100644 index 000000000000..107d32564485 --- /dev/null +++ b/public/js/dist/filterFormManager.min.js @@ -0,0 +1 @@ +import{SelectFilterInput,AssignedEntityFilterInput,DateFilterInput,TextFilterInput}from"/js/dist/filterInputs.min.js";import{container}from"/js/dist/simpleDIContainer.min.js";export default class FilterFormManager{constructor(){this.filters=[],this.inputs=[],this.apiService=container.resolve("apiService")}async collectFilterInputs(){this.inputs=[];const e=[];return document.querySelectorAll('select[id^="advancedSearch_"]:not(.no-select2)').forEach((t=>{e.push(new Promise((e=>{setTimeout((()=>{this.inputs.push(new SelectFilterInput(t,this.apiService)),e()}),0)})))})),document.querySelectorAll(".input-daterange.input-group.date-range-input").forEach((e=>{this.inputs.push(new DateFilterInput(e,this.apiService))})),document.querySelectorAll('input[id^="advancedSearch_"][type="text"]').forEach((t=>{e.push(new Promise((e=>{queueMicrotask((()=>t.classList.contains("input-daterange-field")?e():t.classList.contains("advancedSearch_polymorphicItemFormatter")?(this.inputs.push(new AssignedEntityFilterInput(t,this.apiService)),e()):(this.inputs.push(new TextFilterInput(t,this.apiService)),void e())))})))})),await Promise.all(e),this.inputs}collectFilterData(){return this.filters=[],this.inputs.forEach((e=>{e.appendTo(this.filters)})),this.filters}clearAll(){this.inputs.forEach((e=>{e.clear()}))}setValuesFromResponse(e){this.clearAll();const t=[];for(const i of e){const{field:e,value:s,logic:n,operator:r}=i,a=this.inputs.find((t=>t.key===e));if(a)try{const e=a.setValue(s,n,r);e instanceof Promise&&t.push(e)}catch(t){console.error(`Failed to set value for "${e}":`,t)}else console.warn(`No input found for key: ${e}`)}return Promise.all(t).then((e=>(this.setAdvancedSearchPanelFilterEnabledState(!1),e)))}setAdvancedSearchPanelFilterEnabledState(e){queueMicrotask((()=>{const t=document.getElementById("advancedSearchPanel").getElementsByTagName("*");for(const i of t)i.disabled=e}))}} diff --git a/public/js/dist/filterInputs.js b/public/js/dist/filterInputs.js new file mode 100644 index 000000000000..bb3b50b3f5eb --- /dev/null +++ b/public/js/dist/filterInputs.js @@ -0,0 +1,340 @@ +class FilterInput { + constructor(element, apiService) { + this.element = element; + this.apiService = apiService; + } + + get key() { + return this.element.id + .replace("advancedSearch_", "") + .replace("_input", ""); + } + + setSearchOperator(logic, operator) { + const data = this.element.id.replace("advancedSearch_", "").replace("_input", "").replace("_start", "").replace("_end", ""); + const filterOptionsDropdown = document.querySelector('[data-field="' + data + '"]'); + filterOptionsDropdown.value = logic + "_" + operator; + } + + hasValue() { + return Boolean(this.element.value); + } + + getValue() { + throw new Error("getValue() must be implemented by subclass"); + } + + setValue(newValue, logic, operator) { + return new Promise((resolve, reject) => { + try { + queueMicrotask(() => { + this.element.value = newValue; + this.setSearchOperator(logic, operator) + }); + + } + catch (e) { + reject(e); + } + resolve(newValue); + }) + } + + getType() { + return this.element.id + .replace("advancedSearch_", "") + .replace("_input", "") + .replace("_start", "") + .replace("_end", ""); + } + + appendTo(filters) { + const value = this.getValue(); + + // Skip empty values + const isEmptyArray = Array.isArray(value) && value.length === 0; + const isArrayOfEmptyStrings = Array.isArray(value) && value.every(v => v === ""); + const isTrulyEmpty = value === null || value === undefined || jQuery.isEmptyObject(value) === true; + + if (isTrulyEmpty || isEmptyArray || isArrayOfEmptyStrings) { + return; + } + + const field = this.key; + + const basefield = field + .replace("_start", "") + .replace("_end", ""); + + const filterOptionSelect = document.querySelector(`.filter-option[data-field="${basefield}"]`) + + if (!filterOptionSelect) { + const isDateRange = field.endsWith('_start') || field.endsWith('_end'); + if (!isDateRange) { + console.warn(`No filter option select found for field: ${field}`); + } + return; + } + + let operator = "contains"; + let logic = "AND"; + switch (filterOptionSelect.value) { + case "AND_equals": + operator = "equals"; + logic = "AND"; + break; + case "AND_contains": + operator = "contains"; + logic = "AND"; + break; + case "NOT_equals": + operator = "equals"; + logic = "NOT" + break; + case "NOT_contains": + operator = "contains"; + logic = "NOT"; + break; + } + + filters.push({ + field, + value, + operator, + logic + }) + } + + clear() { + // Reset filter options + const data = this.element.id.replace("advancedSearch_", "").replace("_input", "").replace("_start", "").replace("_end", ""); + const filterOptionsDropdown = document.querySelector('[data-field="' + data + '"]'); + + if (filterOptionsDropdown && filterOptionsDropdown.value) { + filterOptionsDropdown.value = "AND_equals"; + } else { + console.warn("No filterOptionsDropdown found with datafield " + data); + } + + } +} + +class SelectFilterInput extends FilterInput { + + getValue() { + const selections = $(this.element).select2('data'); + + const selectedValues = selections.map(item => { + const parseId = parseInt(item.id, 10); + return isNaN(parseId) ? item.id : parseId; + }) + + if (selectedValues.length === 0) { + return null; + } + + return selectedValues; + } + + setValue(newValues, logic, operator) { + const requestPromises = newValues.map((newValue) => { + return Promise.resolve().then(() => { + this.setSearchOperator(logic, operator); + + // Normalize the newValue + const isObject = typeof newValue === "object" && newValue !== null; + const id = isObject ? newValue.id : newValue; + const name = isObject ? newValue.name : newValue; + + const $el = $(this.element); + let existingOption = $el.find(`option[value='${id}']`); + + if (existingOption.length === 0) { + const option = new Option(name, id, true, true); + $el.append(option); + } else { + existingOption.prop("selected", true); + } + + $el.trigger("change"); + return { id, name }; + }); + }); + + return Promise.all(requestPromises); + } + + + + + clear() { + $(this.element).val(null).trigger('change'); + super.clear(); + } +} + +class DateFilterInput extends FilterInput { + constructor(el, apiService) { + super(el, apiService); + + // assign as class properties + this.startDatepickerInput = document.getElementById(el.id + "_start"); + this.endDatepickerInput = document.getElementById(el.id + "_end"); + + if (this.startDatepickerInput) { + this.startDatepicker = $(this.startDatepickerInput).datepicker({ + todayBtn: "linked", + clearBtn: true, + disableTouchKeyboard: true, + forceParse: false, + keepEmptyValues: true, + daysOfWeekHighlighted: "0,6", + todayHighlight: true, + format: "yyyy-mm-dd", + }); + } + + if (this.endDatepickerInput) { + this.endDatepicker = $(this.endDatepickerInput).datepicker({ + todayBtn: "linked", + clearBtn: true, + disableTouchKeyboard: true, + forceParse: false, + keepEmptyValues: true, + daysOfWeekHighlighted: "0,6", + todayHighlight: true, + format: "yyyy-mm-dd", + }); + } + + // event listeners (check element exists) + if (this.startDatepickerInput) { + $(this.startDatepickerInput).on('changeDate', (event) => { + this.startDate = new Intl.DateTimeFormat('en-CA').format(event.date); + }); + + $(this.startDatepickerInput).on('clearDate', (_) => { + this.startDate = undefined; + }); + } + + if (this.endDatepickerInput) { + $(this.endDatepickerInput).on('changeDate', (event) => { + this.endDate = new Intl.DateTimeFormat('en-CA').format(event.date); + }); + + $(this.endDatepickerInput).on('clearDate', (_) => { + this.endDate = undefined; + }); + } + } + + + getValue() { + const result = {}; + + if (this.startDate != undefined) { + result.startDate = this.startDate; + } + if (this.endDate != undefined) { + result.endDate = this.endDate; + } + return result; + } + + setValue(newValue, logic, operator) { + + return new Promise((resolve, reject) => { + try { + + if (newValue.startDate != undefined) { + const startDateObject = new Date(newValue.startDate); + this.startDatepicker.datepicker('setDate', startDateObject); + } + + if (newValue.endDate != undefined) { + const endDateObject = new Date(newValue.endDate); + this.endDatepicker.datepicker('setDate', endDateObject); + } + + this.setSearchOperator(logic, operator); + resolve(newValue); + } + catch (e) { + console.error('Error setting dates:', e); + reject(e); + } + }); + } + + clear() { + const r = this.getValue(); + if (r.startDate !== undefined || jQuery.isEmptyObject(r) === false) { + this.startDatepicker.datepicker('clearDates'); + this.startDate = undefined; + } + if (r.endDate !== undefined || jQuery.isEmptyObject(r) === false) { + this.endDatepicker.datepicker('clearDates'); + this.endDate = undefined; + } + + super.clear(); + } +} + +class TextFilterInput extends FilterInput { + getValue() { + return this.hasValue() ? this.element.value : null; + } + clear() { + this.element.value = ""; + super.clear(); + } +} + +class AssignedEntityFilterInput extends TextFilterInput { + getValue() { + const value = this.hasValue() ? this.element.value : null; + const type = document.getElementById(this.element.id + "_type").value; + + if (!value || !type) { + return null; + } + + return { + type: type, + value: value + } + } + + setValue(newValue, logic, operator) { + return new Promise((resolve, reject) => { + try { + queueMicrotask(() => { + this.element.value = newValue.value; + document.getElementById(this.element.id + "_type").value = newValue.type; + this.setSearchOperator(logic, operator) + }); + + } + catch (e) { + reject(e); + } + resolve(newValue); + }) + } + + clear() { + this.element.value = ""; + document.getElementById(this.element.id + "_type").value = ""; + super.clear(); + } + +} +export { + FilterInput, + SelectFilterInput, + AssignedEntityFilterInput, + DateFilterInput, + TextFilterInput +} \ No newline at end of file diff --git a/public/js/dist/filterInputs.min.js b/public/js/dist/filterInputs.min.js new file mode 100644 index 000000000000..385ff3025da9 --- /dev/null +++ b/public/js/dist/filterInputs.min.js @@ -0,0 +1 @@ +class FilterInput{constructor(e,t){this.element=e,this.apiService=t}get key(){return this.element.id.replace("advancedSearch_","").replace("_input","")}setSearchOperator(e,t){const a=this.element.id.replace("advancedSearch_","").replace("_input","").replace("_start","").replace("_end","");document.querySelector('[data-field="'+a+'"]').value=e+"_"+t}hasValue(){return Boolean(this.element.value)}getValue(){throw new Error("getValue() must be implemented by subclass")}setValue(e,t,a){return new Promise(((r,n)=>{try{queueMicrotask((()=>{this.element.value=e,this.setSearchOperator(t,a)}))}catch(e){n(e)}r(e)}))}getType(){return this.element.id.replace("advancedSearch_","").replace("_input","").replace("_start","").replace("_end","")}appendTo(e){const t=this.getValue(),a=Array.isArray(t)&&0===t.length,r=Array.isArray(t)&&t.every((e=>""===e));if(null==t||!0===jQuery.isEmptyObject(t)||a||r)return;const n=this.key,s=n.replace("_start","").replace("_end",""),i=document.querySelector(`.filter-option[data-field="${s}"]`);if(!i){return void(n.endsWith("_start")||n.endsWith("_end")||console.warn(`No filter option select found for field: ${n}`))}let l="contains",c="AND";switch(i.value){case"AND_equals":l="equals",c="AND";break;case"AND_contains":l="contains",c="AND";break;case"NOT_equals":l="equals",c="NOT";break;case"NOT_contains":l="contains",c="NOT"}e.push({field:n,value:t,operator:l,logic:c})}clear(){const e=this.element.id.replace("advancedSearch_","").replace("_input","").replace("_start","").replace("_end",""),t=document.querySelector('[data-field="'+e+'"]');t&&t.value?t.value="AND_equals":console.warn("No filterOptionsDropdown found with datafield "+e)}}class SelectFilterInput extends FilterInput{getValue(){const e=$(this.element).select2("data").map((e=>{const t=parseInt(e.id,10);return isNaN(t)?e.id:t}));return 0===e.length?null:e}setValue(e,t,a){const r=e.map((e=>Promise.resolve().then((()=>{this.setSearchOperator(t,a);const r="object"==typeof e&&null!==e,n=r?e.id:e,s=r?e.name:e,i=$(this.element);let l=i.find(`option[value='${n}']`);if(0===l.length){const e=new Option(s,n,!0,!0);i.append(e)}else l.prop("selected",!0);return i.trigger("change"),{id:n,name:s}}))));return Promise.all(r)}clear(){$(this.element).val(null).trigger("change"),super.clear()}}class DateFilterInput extends FilterInput{constructor(e,t){super(e,t),this.startDatepickerInput=document.getElementById(e.id+"_start"),this.endDatepickerInput=document.getElementById(e.id+"_end"),this.startDatepickerInput&&(this.startDatepicker=$(this.startDatepickerInput).datepicker({todayBtn:"linked",clearBtn:!0,disableTouchKeyboard:!0,forceParse:!1,keepEmptyValues:!0,daysOfWeekHighlighted:"0,6",todayHighlight:!0,format:"yyyy-mm-dd"})),this.endDatepickerInput&&(this.endDatepicker=$(this.endDatepickerInput).datepicker({todayBtn:"linked",clearBtn:!0,disableTouchKeyboard:!0,forceParse:!1,keepEmptyValues:!0,daysOfWeekHighlighted:"0,6",todayHighlight:!0,format:"yyyy-mm-dd"})),this.startDatepickerInput&&($(this.startDatepickerInput).on("changeDate",(e=>{this.startDate=new Intl.DateTimeFormat("en-CA").format(e.date)})),$(this.startDatepickerInput).on("clearDate",(e=>{this.startDate=void 0}))),this.endDatepickerInput&&($(this.endDatepickerInput).on("changeDate",(e=>{this.endDate=new Intl.DateTimeFormat("en-CA").format(e.date)})),$(this.endDatepickerInput).on("clearDate",(e=>{this.endDate=void 0})))}getValue(){const e={};return null!=this.startDate&&(e.startDate=this.startDate),null!=this.endDate&&(e.endDate=this.endDate),e}setValue(e,t,a){return new Promise(((r,n)=>{try{if(null!=e.startDate){const t=new Date(e.startDate);this.startDatepicker.datepicker("setDate",t)}if(null!=e.endDate){const t=new Date(e.endDate);this.endDatepicker.datepicker("setDate",t)}this.setSearchOperator(t,a),r(e)}catch(e){console.error("Error setting dates:",e),n(e)}}))}clear(){const e=this.getValue();void 0===e.startDate&&!1!==jQuery.isEmptyObject(e)||(this.startDatepicker.datepicker("clearDates"),this.startDate=void 0),void 0===e.endDate&&!1!==jQuery.isEmptyObject(e)||(this.endDatepicker.datepicker("clearDates"),this.endDate=void 0),super.clear()}}class TextFilterInput extends FilterInput{getValue(){return this.hasValue()?this.element.value:null}clear(){this.element.value="",super.clear()}}class AssignedEntityFilterInput extends TextFilterInput{getValue(){const e=this.hasValue()?this.element.value:null,t=document.getElementById(this.element.id+"_type").value;return e&&t?{type:t,value:e}:null}setValue(e,t,a){return new Promise(((r,n)=>{try{queueMicrotask((()=>{this.element.value=e.value,document.getElementById(this.element.id+"_type").value=e.type,this.setSearchOperator(t,a)}))}catch(e){n(e)}r(e)}))}clear(){this.element.value="",document.getElementById(this.element.id+"_type").value="",super.clear()}}export{FilterInput,SelectFilterInput,AssignedEntityFilterInput,DateFilterInput,TextFilterInput}; diff --git a/public/js/dist/filterUiController.js b/public/js/dist/filterUiController.js new file mode 100644 index 000000000000..50842c49ca5e --- /dev/null +++ b/public/js/dist/filterUiController.js @@ -0,0 +1,198 @@ +import { container } from '/js/dist/simpleDIContainer.min.js'; + +class FilterUIController { + constructor(tableElement) { + this.$table = tableElement; + this.apiService = container.resolve("apiService"); + this.collector = container.resolve("filterFormManager"); + this.translations = container.resolve("advancedSearchTranslations"); + + // store handler references so we can unbind them + this.handlePredefinedChange = (e) => this.updateFilterWithPredefined(e); + this.handleFilterClick = this.refresh.bind(this); + this.handleClearClick = () => { + this.collector.clearAll(); + $('#predefinedfilters-select').val(null).trigger('change'); + }; + this.handleSaveClick = () => this.storePredefinedFilterInBackend(); + this.handleUpdateClick = (e) => this.updatePredefinedFilterInBackend(e.target.id); + this.handleDeleteClick = (e) => this.deletePredefinedFilterFromBackend(e.target.id); + + } + + async init(){ + await this.collector.collectFilterInputs(); + } + + destroy() { + document.removeEventListener('click', this._boundDocumentClick, true); + window.removeEventListener('keydown', this._boundKeydown); + // also null any large references & call other destroy methods (floating buttons, etc) + this._boundDocumentClick = null; + this._boundKeydown = null; + } + + refresh() { + const filters = this.collector.collectFilterData(); + this.$table.bootstrapTable('refresh', { + query: { + filter: JSON.stringify(filters) + } + }); + } + + async updateFilterWithPredefined(event, selectedId = null) { + if (event !== null) { + selectedId = event?.target?.value; + } + + const floatingButtons = container.resolve("floatingButtons"); + + if (!selectedId) { + floatingButtons.disableEditDeleteButtons(); + return; + } + + floatingButtons.enableEditDeleteButtons(); + + try { + const response = await this.apiService.fetchPredefinedFilterData(selectedId); + if (!response.ok) { + throw new Error("Network response was not ok"); + } + + const data = await response.json(); + await this.collector.setValuesFromResponse(data.filter_data); + this.refresh(); + + } catch (err) { + console.error("Failed to apply predefined filter:", err); + Livewire.dispatch('showNotification', { + type: 'error', + message: this.translations.general_failed_to_apply_predefined_filter + }); + } + } + + storePredefinedFilterInBackend() { + const filters = this.collector.collectFilterData(); + + if (!filters || filters.length === 0) { + Livewire.dispatch('showNotification', { + type: "error", + title: this.translations.error, + message: this.translations.general_can_not_save_empty_filter + }); + return; + } + + Livewire.dispatch('openPredefinedFiltersModal', { + action: 'create', + predefinedFilterData: filters + }); + } + + updatePredefinedFilterInBackend(updateFilterButtonId) { + const updateBtn = document.getElementById(updateFilterButtonId); + if (updateBtn.classList.contains('disabled')) return; + + const selectedFilter = $("#predefinedfilters-select").select2('data')[0]; + if (!selectedFilter) return; + + const filters = this.collector.collectFilterData(); + + if (!filters || filters.length === 0) { + Livewire.dispatch('showNotification', { + type: "error", + title: this.translations.error, + message: this.translations.general_can_not_update_empty_filter + }); + return; + } + + Livewire.dispatch('openPredefinedFiltersModal', { + action: 'edit', + predefinedFilterId: parseInt(selectedFilter.id, 10), + predefinedFilterData: filters + }); + } + + deletePredefinedFilterFromBackend(deleteFilterButtonId) { + const deleteBtn = document.getElementById(deleteFilterButtonId); + if (deleteBtn.classList.contains('disabled')) return; + + const selected = $("#predefinedfilters-select").select2('data')[0]; + if (!selected || !selected.id) return; + + Livewire.dispatch('openPredefinedFiltersModal', { + action: 'delete', + predefinedFilterId: parseInt(selected.id, 10) + }); + } + + bindEvents() { + $('#predefinedfilters-select').on('change', this.handlePredefinedChange); + + const filterButton = document.getElementById("filterButton"); + if (filterButton) { + filterButton.addEventListener('click', this.handleFilterClick); + } + + const clearButtons = ["clearInputButton", "topClearInputButton"]; + clearButtons.forEach(id => { + const btn = document.getElementById(id); + if (btn) { + btn.addEventListener('click', this.handleClearClick); + } + }); + + const saveButton = document.getElementById("storeFilterButton"); + if (saveButton) { + saveButton.addEventListener('click', this.handleSaveClick); + } + + const updateButton = document.getElementById("updateFilterButton"); + if (updateButton) { + updateButton.addEventListener('click', this.handleUpdateClick); + } + + const deleteButton = document.getElementById("deleteFilterButton"); + if (deleteButton) { + deleteButton.addEventListener('click', this.handleDeleteClick); + } + } + + unbindEvents() { + $('#predefinedfilters-select').off('change', this.handlePredefinedChange); + + const filterButton = document.getElementById("filterButton"); + if (filterButton) { + filterButton.removeEventListener('click', this.handleFilterClick); + } + + const clearButtons = ["clearInputButton", "topClearInputButton"]; + clearButtons.forEach(id => { + const btn = document.getElementById(id); + if (btn) { + btn.removeEventListener('click', this.handleClearClick); + } + }); + + const saveButton = document.getElementById("storeFilterButton"); + if (saveButton) { + saveButton.removeEventListener('click', this.handleSaveClick); + } + + const updateButton = document.getElementById("updateFilterButton"); + if (updateButton) { + updateButton.removeEventListener('click', this.handleUpdateClick); + } + + const deleteButton = document.getElementById("deleteFilterButton"); + if (deleteButton) { + deleteButton.removeEventListener('click', this.handleDeleteClick); + } + } +} + +export default FilterUIController; \ No newline at end of file diff --git a/public/js/dist/filterUiController.min.js b/public/js/dist/filterUiController.min.js new file mode 100644 index 000000000000..b3951ed1d4bd --- /dev/null +++ b/public/js/dist/filterUiController.min.js @@ -0,0 +1 @@ +import{container}from"/js/dist/simpleDIContainer.min.js";class FilterUIController{constructor(e){this.$table=e,this.apiService=container.resolve("apiService"),this.collector=container.resolve("filterFormManager"),this.translations=container.resolve("advancedSearchTranslations"),this.handlePredefinedChange=e=>this.updateFilterWithPredefined(e),this.handleFilterClick=this.refresh.bind(this),this.handleClearClick=()=>{this.collector.clearAll(),$("#predefinedfilters-select").val(null).trigger("change")},this.handleSaveClick=()=>this.storePredefinedFilterInBackend(),this.handleUpdateClick=e=>this.updatePredefinedFilterInBackend(e.target.id),this.handleDeleteClick=e=>this.deletePredefinedFilterFromBackend(e.target.id)}async init(){await this.collector.collectFilterInputs()}destroy(){document.removeEventListener("click",this._boundDocumentClick,!0),window.removeEventListener("keydown",this._boundKeydown),this._boundDocumentClick=null,this._boundKeydown=null}refresh(){const e=this.collector.collectFilterData();this.$table.bootstrapTable("refresh",{query:{filter:JSON.stringify(e)}})}async updateFilterWithPredefined(e,t=null){null!==e&&(t=e?.target?.value);const i=container.resolve("floatingButtons");if(t){i.enableEditDeleteButtons();try{const e=await this.apiService.fetchPredefinedFilterData(t);if(!e.ok)throw new Error("Network response was not ok");const i=await e.json();await this.collector.setValuesFromResponse(i.filter_data),this.refresh()}catch(e){console.error("Failed to apply predefined filter:",e),Livewire.dispatch("showNotification",{type:"error",message:this.translations.general_failed_to_apply_predefined_filter})}}else i.disableEditDeleteButtons()}storePredefinedFilterInBackend(){const e=this.collector.collectFilterData();e&&0!==e.length?Livewire.dispatch("openPredefinedFiltersModal",{action:"create",predefinedFilterData:e}):Livewire.dispatch("showNotification",{type:"error",title:this.translations.error,message:this.translations.general_can_not_save_empty_filter})}updatePredefinedFilterInBackend(e){if(document.getElementById(e).classList.contains("disabled"))return;const t=$("#predefinedfilters-select").select2("data")[0];if(!t)return;const i=this.collector.collectFilterData();i&&0!==i.length?Livewire.dispatch("openPredefinedFiltersModal",{action:"edit",predefinedFilterId:parseInt(t.id,10),predefinedFilterData:i}):Livewire.dispatch("showNotification",{type:"error",title:this.translations.error,message:this.translations.general_can_not_update_empty_filter})}deletePredefinedFilterFromBackend(e){if(document.getElementById(e).classList.contains("disabled"))return;const t=$("#predefinedfilters-select").select2("data")[0];t&&t.id&&Livewire.dispatch("openPredefinedFiltersModal",{action:"delete",predefinedFilterId:parseInt(t.id,10)})}bindEvents(){$("#predefinedfilters-select").on("change",this.handlePredefinedChange);const e=document.getElementById("filterButton");e&&e.addEventListener("click",this.handleFilterClick);["clearInputButton","topClearInputButton"].forEach((e=>{const t=document.getElementById(e);t&&t.addEventListener("click",this.handleClearClick)}));const t=document.getElementById("storeFilterButton");t&&t.addEventListener("click",this.handleSaveClick);const i=document.getElementById("updateFilterButton");i&&i.addEventListener("click",this.handleUpdateClick);const n=document.getElementById("deleteFilterButton");n&&n.addEventListener("click",this.handleDeleteClick)}unbindEvents(){$("#predefinedfilters-select").off("change",this.handlePredefinedChange);const e=document.getElementById("filterButton");e&&e.removeEventListener("click",this.handleFilterClick);["clearInputButton","topClearInputButton"].forEach((e=>{const t=document.getElementById(e);t&&t.removeEventListener("click",this.handleClearClick)}));const t=document.getElementById("storeFilterButton");t&&t.removeEventListener("click",this.handleSaveClick);const i=document.getElementById("updateFilterButton");i&&i.removeEventListener("click",this.handleUpdateClick);const n=document.getElementById("deleteFilterButton");n&&n.removeEventListener("click",this.handleDeleteClick)}}export default FilterUIController; diff --git a/public/js/dist/floating-buttons.js b/public/js/dist/floating-buttons.js new file mode 100644 index 000000000000..f1d127bc7c9a --- /dev/null +++ b/public/js/dist/floating-buttons.js @@ -0,0 +1,411 @@ +// resources/js/modules/floating-buttons.js + +// Enums +const PositionMode = Object.freeze({ + FIXED: 'fixed', + SCROLLABLE: 'scrollable', +}); + +export default class FloatingButtons { + constructor() { + this.advancedSearchPanel = document.getElementById('advancedSearchPanel'); + this.floatingButtonContainer = document.getElementById('floatingButtonContainer'); + this.menuToggleButton = document.getElementById('menuToggleButton'); + this.fabMenu = document.getElementById('fabMenu'); + this.menuItems = this.fabMenu?.querySelectorAll('[role="menuitem"]'); + this.menuOpen = false; + + // rAF throttle flag + this._ticking = false; + + // store original parent/sibling to restore when switching back to fixed + this._originalParent = this.floatingButtonContainer?.parentNode || document.body; + this._originalNextSibling = this.floatingButtonContainer?.nextSibling || null; + + // remember if we changed panel positioning so we can avoid overwriting styles + this._panelPositionWasStatic = false; + + // saved inline height so we can restore when switching back to fixed mode + this._savedPanelInlineHeight = this.advancedSearchPanel ? this.advancedSearchPanel.style.height || '' : ''; + + // buffer used when making the panel taller to accommodate the floating buttons + this._heightBuffer = 70; + + // measured content height (natural) + this.advancedSearchPanelHeight = this.advancedSearchPanel ? this.advancedSearchPanel.scrollHeight : 0; + this.advancedSearchPanelExtended = false; + + this.init(); + } + + init() { + if (!this.floatingButtonContainer) return; + + // Ensure we have an inner wrapper to animate transforms/opacity + this._ensureInnerWrapper(); + + this.bindEvents(); + this.align(); + this.updatePositionMode(); + // refresh stored natural height + this.advancedSearchPanelHeight = this._getNaturalPanelHeight(); + + // observe changes if sidepanel is open / closed + this._observePanelSize(); + } + + + + _observePanelSize() { + if (!this.advancedSearchPanel) return; + + let lastWidth = this.advancedSearchPanel.offsetWidth; + let lastHeight = this._getNaturalPanelHeight(); + + const ro = new ResizeObserver(entries => { + for (const entry of entries) { + const newWidth = entry.contentRect.width; + const newHeight = this._getNaturalPanelHeight(); + + // Only trigger when size changes (with / height) + if (newWidth !== lastWidth || newHeight !== lastHeight) { + lastWidth = newWidth; + lastHeight = newHeight; + + this.align(); + this.updatePositionMode(); + } + } + }); + + ro.observe(this.advancedSearchPanel); + } + + + destroy() { + if (this._resizeObserver) { + this._resizeObserver.disconnect(); + this._resizeObserver = null; + } + if (this._boundResizeFallback) { + window.removeEventListener('resize', this._boundResizeFallback); + } + } + + // Wrap existing children in a .floating-buttons-inner so we can animate transforms + _ensureInnerWrapper() { + if (!this.floatingButtonContainer) return; + + const existing = this.floatingButtonContainer.querySelector('.floating-buttons-inner'); + if (existing) { + this.inner = existing; + return; + } + + const inner = document.createElement('div'); + inner.className = 'floating-buttons-inner'; + + while (this.floatingButtonContainer.firstChild) { + inner.appendChild(this.floatingButtonContainer.firstChild); + } + + this.floatingButtonContainer.appendChild(inner); + this.inner = inner; + + // re-query nodes moved into inner + this.menuToggleButton = this.floatingButtonContainer.querySelector('#menuToggleButton') || this.menuToggleButton; + this.fabMenu = this.floatingButtonContainer.querySelector('#fabMenu') || this.fabMenu; + this.menuItems = this.fabMenu?.querySelectorAll('[role="menuitem"]'); + } + + bindEvents() { + queueMicrotask(() => { + // Menu events + this.menuToggleButton?.addEventListener('click', () => this.toggleMenu()); + + this.menuToggleButton?.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this.toggleMenu(); + } + }); + + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + this.closeMenu(); + try { this.menuToggleButton.focus(); } catch (err) { /* empty */ } + } + }); + + document.addEventListener('click', (e) => { + if (!this.fabMenu.contains(e.target) && !this.menuToggleButton.contains(e.target)) { + this.closeMenu(); + } + }); + + const menuButtonItems = document.querySelectorAll('.floating-buttons-menuButton'); + menuButtonItems.forEach((item) => { + item.addEventListener('click', () => this.closeMenu()); + }); + + // Window events — schedule with rAF for smoothness + const scheduleFullUpdate = () => { + if (!this._ticking) { + this._ticking = true; + window.requestAnimationFrame(() => { + this.align(); + this.updatePositionMode(); + this._ticking = false; + }); + } + }; + + window.addEventListener('resize', scheduleFullUpdate, { passive: true }); + window.addEventListener('orientationchange', scheduleFullUpdate, { passive: true }); + window.addEventListener('load', scheduleFullUpdate, { passive: true }); + + window.addEventListener('scroll', () => { + // on scroll we only need to check the mode; throttle via rAF + if (!this._ticking) { + this._ticking = true; + window.requestAnimationFrame(() => { + this.updatePositionMode(); + this._ticking = false; + }); + } + }, { passive: true }); + }); + } + + align() { + if (!this.advancedSearchPanel || !this.floatingButtonContainer) return; + + queueMicrotask(() => { + const panelRect = this.advancedSearchPanel.getBoundingClientRect(); + const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft; + const centerX = panelRect.left + (panelRect.width / 2) + scrollLeft; + + // Only set absolute page-based left when the container is in the document root (fixed mode). + if (this.floatingButtonContainer.classList.contains('floating-buttons-fab-fixed-wrapper')) { + this.floatingButtonContainer.style.left = `${centerX}px`; + this.floatingButtonContainer.style.transform = 'translateX(-50%)'; + } else { + // when positioned inside the panel (absolute), use left:50% + translateX(-50%) to center relative to panel + this.floatingButtonContainer.style.left = '50%'; + this.floatingButtonContainer.style.transform = 'translateX(-50%)'; + } + }); + } + + updatePositionMode() { + if (!this.advancedSearchPanel || !this.floatingButtonContainer) return; + + // always refresh natural height (don't rely on stale stored value) + this.advancedSearchPanelHeight = this._getNaturalPanelHeight(); + + queueMicrotask(() => { + const panelRect = this.advancedSearchPanel.getBoundingClientRect(); + + // Check if panel is actually visible and has height + const panelVisible = panelRect.height > 0 && + window.getComputedStyle(this.advancedSearchPanel).display !== 'none' && + window.getComputedStyle(this.advancedSearchPanel).visibility !== 'hidden'; + + // If panel isn't visible, force fixed + if (!panelVisible) { + this._setFixedMode(); + return; + } + + const buttonAreaHeight = 90; + const viewportHeight = window.innerHeight; + const buttonZoneTop = viewportHeight - buttonAreaHeight; + + // If panel extends into the button zone, make buttons fixed; otherwise make them scrollable (absolute inside panel) + const wouldOverlap = panelRect.bottom > buttonZoneTop; + + const overlapThreshold = 50; + + let newMode = this._currentMode; + + // this is needed to track which mode was the last one + // then we could implement an easy overlap + if (!wouldOverlap) { + if (this._currentMode === PositionMode.FIXED && panelRect.bottom > buttonZoneTop - overlapThreshold) { + newMode = PositionMode.FIXED; // stay fixed in buffer zone + } else { + newMode = PositionMode.SCROLLABLE; + } + } else { + if (this._currentMode === PositionMode.SCROLLABLE && panelRect.bottom < buttonZoneTop + overlapThreshold) { + newMode = PositionMode.SCROLLABLE; // stay scrollable in buffer zone + } else { + newMode = PositionMode.FIXED; + } + } + + // Only update mode if it changed + if (newMode !== this._currentMode) { + if (newMode === PositionMode.FIXED) { + this._setFixedMode(); + } else { + this._setScrollableMode(); + } + this._currentMode = newMode; + } + }); + } + + // helper to get the natural content height of the panel without any inline height applied + _getNaturalPanelHeight() { + if (!this.advancedSearchPanel) return 0; + const prevInline = this.advancedSearchPanel.style.height; + // Temporarily clear inline height to measure natural content height + this.advancedSearchPanel.style.height = ''; + const natural = this.advancedSearchPanel.scrollHeight; + // restore previous inline height + this.advancedSearchPanel.style.height = prevInline; + return natural; + } + + // Move the floating container inside the panel and set class for absolute positioning + _setScrollableMode() { + if (!this.floatingButtonContainer || !this.advancedSearchPanel) return; + + // ensure panel can be a positioned ancestor + const panelStyle = window.getComputedStyle(this.advancedSearchPanel); + if (panelStyle.position === 'static') { + this._panelPositionWasStatic = true; + this.advancedSearchPanel.style.position = 'relative'; + } + + // move container into the panel so position:absolute makes it scroll with the panel + this.advancedSearchPanel.appendChild(this.floatingButtonContainer); + + // switch classes + this.floatingButtonContainer.classList.remove('floating-buttons-fab-fixed-wrapper'); + this.floatingButtonContainer.classList.add('floating-buttons-fab-scrollable-wrapper'); + + // measure natural content height and ensure there's extra space for the buttons to sit comfortably + this.advancedSearchPanelHeight = this._getNaturalPanelHeight(); + + this.advancedSearchPanelExtended = false; + + this.advancedSearchPanel.classList.add('advancedSearchPanel--withBuffer'); + this.advancedSearchPanel.style.paddingBottom = `${this._heightBuffer}px`; + } + + // Move the floating container back to its original parent and set fixed class + _setFixedMode() { + if (!this.floatingButtonContainer) return; + + // if already fixed, nothing to do + if (this.floatingButtonContainer.classList.contains('floating-buttons-fab-fixed-wrapper')) { + return; + } + + // align using panel metrics to compute fixed-left position before moving the container + const panelRect = this.advancedSearchPanel ? this.advancedSearchPanel.getBoundingClientRect() : null; + if (panelRect) { + const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft; + const centerX = panelRect.left + (panelRect.width / 2) + scrollLeft; + this.floatingButtonContainer.style.left = `${centerX}px`; + this.floatingButtonContainer.style.transform = 'translateX(-50%)'; + } + + // restore panel position style if we changed it earlier + if (this._panelPositionWasStatic && this.advancedSearchPanel) { + this.advancedSearchPanel.style.position = ''; + this._panelPositionWasStatic = false; + } + + // restore container to original parent in original position + try { + if (this._originalNextSibling && this._originalNextSibling.parentNode === this._originalParent) { + this._originalParent.insertBefore(this.floatingButtonContainer, this._originalNextSibling); + } else { + this._originalParent.appendChild(this.floatingButtonContainer); + } + } catch (e) { + // fallback: append to body + document.body.appendChild(this.floatingButtonContainer); + } + + // switch classes + this.floatingButtonContainer.classList.remove('floating-buttons-fab-scrollable-wrapper'); + this.floatingButtonContainer.classList.add('floating-buttons-fab-fixed-wrapper'); + + // restore the panel's original inline height (if we saved one) so we don't keep a reduced height + if (this.advancedSearchPanel) { + this.advancedSearchPanelExtended = true; + // restore saved inline height (may be empty string, which will clear the inline style) + this.advancedSearchPanel.classList.remove('advancedSearchPanel--withBuffer'); + this.advancedSearchPanel.style.height = this._savedPanelInlineHeight || ''; + } + } + + // Public setter so other code can explicitly set the mode + setFloatingMode(mode) { + if (mode === 'scrollable') { + this._setScrollableMode(); + } else { + this._setFixedMode(); + } + } + + show() { + if (!this.floatingButtonContainer) return; + this.floatingButtonContainer.style.visibility = 'visible'; + } + + hide() { + if (!this.floatingButtonContainer) return; + this.floatingButtonContainer.style.visibility = 'hidden'; + } + + toggleMenu() { + queueMicrotask(() => { + this.menuOpen = !this.menuOpen; + try { + + if (this.menuOpen) { + this.fabMenu?.classList.add('open'); + this.fabMenu && this.fabMenu.setAttribute('aria-hidden', 'false'); + this.menuToggleButton?.setAttribute('aria-expanded', 'true'); + + this.menuItems?.forEach((item) => { + item.setAttribute('tabindex', '0'); + }); + this.menuItems?.[0]?.focus(); + } else { + this.fabMenu?.classList.remove('open'); + this.fabMenu && this.fabMenu.setAttribute('aria-hidden', 'true'); + this.menuToggleButton?.setAttribute('aria-expanded', 'false'); + + this.menuItems?.forEach(item => item.setAttribute('tabindex', '-1')); + } + } catch { /* empty */ } + }); + } + + closeMenu() { + if (!this.menuOpen) return; + queueMicrotask(() => { + this.menuOpen = false; + this.fabMenu?.classList.remove('open'); + this.fabMenu && this.fabMenu.setAttribute('aria-hidden', 'true'); + this.menuToggleButton?.setAttribute('aria-expanded', 'false'); + this.menuItems?.forEach(item => item.setAttribute('tabindex', '-1')); + }); + } + + enableEditDeleteButtons() { + document.getElementById('updateFilterButton')?.classList.remove('floating-buttons-disabled'); + document.getElementById('deleteFilterButton')?.classList.remove('floating-buttons-disabled'); + } + + disableEditDeleteButtons() { + document.getElementById('updateFilterButton')?.classList.add('floating-buttons-disabled'); + document.getElementById('deleteFilterButton')?.classList.add('floating-buttons-disabled'); + } +} \ No newline at end of file diff --git a/public/js/dist/floating-buttons.min.js b/public/js/dist/floating-buttons.min.js new file mode 100644 index 000000000000..279c278e005f --- /dev/null +++ b/public/js/dist/floating-buttons.min.js @@ -0,0 +1 @@ +const PositionMode=Object.freeze({FIXED:"fixed",SCROLLABLE:"scrollable"});export default class FloatingButtons{constructor(){this.advancedSearchPanel=document.getElementById("advancedSearchPanel"),this.floatingButtonContainer=document.getElementById("floatingButtonContainer"),this.menuToggleButton=document.getElementById("menuToggleButton"),this.fabMenu=document.getElementById("fabMenu"),this.menuItems=this.fabMenu?.querySelectorAll('[role="menuitem"]'),this.menuOpen=!1,this._ticking=!1,this._originalParent=this.floatingButtonContainer?.parentNode||document.body,this._originalNextSibling=this.floatingButtonContainer?.nextSibling||null,this._panelPositionWasStatic=!1,this._savedPanelInlineHeight=this.advancedSearchPanel&&this.advancedSearchPanel.style.height||"",this._heightBuffer=70,this.advancedSearchPanelHeight=this.advancedSearchPanel?this.advancedSearchPanel.scrollHeight:0,this.advancedSearchPanelExtended=!1,this.init()}init(){this.floatingButtonContainer&&(this._ensureInnerWrapper(),this.bindEvents(),this.align(),this.updatePositionMode(),this.advancedSearchPanelHeight=this._getNaturalPanelHeight(),this._observePanelSize())}_observePanelSize(){if(!this.advancedSearchPanel)return;let t=this.advancedSearchPanel.offsetWidth,e=this._getNaturalPanelHeight();new ResizeObserver((n=>{for(const i of n){const n=i.contentRect.width,a=this._getNaturalPanelHeight();n===t&&a===e||(t=n,e=a,this.align(),this.updatePositionMode())}})).observe(this.advancedSearchPanel)}destroy(){this._resizeObserver&&(this._resizeObserver.disconnect(),this._resizeObserver=null),this._boundResizeFallback&&window.removeEventListener("resize",this._boundResizeFallback)}_ensureInnerWrapper(){if(!this.floatingButtonContainer)return;const t=this.floatingButtonContainer.querySelector(".floating-buttons-inner");if(t)return void(this.inner=t);const e=document.createElement("div");for(e.className="floating-buttons-inner";this.floatingButtonContainer.firstChild;)e.appendChild(this.floatingButtonContainer.firstChild);this.floatingButtonContainer.appendChild(e),this.inner=e,this.menuToggleButton=this.floatingButtonContainer.querySelector("#menuToggleButton")||this.menuToggleButton,this.fabMenu=this.floatingButtonContainer.querySelector("#fabMenu")||this.fabMenu,this.menuItems=this.fabMenu?.querySelectorAll('[role="menuitem"]')}bindEvents(){queueMicrotask((()=>{this.menuToggleButton?.addEventListener("click",(()=>this.toggleMenu())),this.menuToggleButton?.addEventListener("keydown",(t=>{"Enter"!==t.key&&" "!==t.key||(t.preventDefault(),this.toggleMenu())})),document.addEventListener("keydown",(t=>{if("Escape"===t.key){this.closeMenu();try{this.menuToggleButton.focus()}catch(t){}}})),document.addEventListener("click",(t=>{this.fabMenu.contains(t.target)||this.menuToggleButton.contains(t.target)||this.closeMenu()}));document.querySelectorAll(".floating-buttons-menuButton").forEach((t=>{t.addEventListener("click",(()=>this.closeMenu()))}));const t=()=>{this._ticking||(this._ticking=!0,window.requestAnimationFrame((()=>{this.align(),this.updatePositionMode(),this._ticking=!1})))};window.addEventListener("resize",t,{passive:!0}),window.addEventListener("orientationchange",t,{passive:!0}),window.addEventListener("load",t,{passive:!0}),window.addEventListener("scroll",(()=>{this._ticking||(this._ticking=!0,window.requestAnimationFrame((()=>{this.updatePositionMode(),this._ticking=!1})))}),{passive:!0})}))}align(){this.advancedSearchPanel&&this.floatingButtonContainer&&queueMicrotask((()=>{const t=this.advancedSearchPanel.getBoundingClientRect(),e=window.pageXOffset||document.documentElement.scrollLeft,n=t.left+t.width/2+e;this.floatingButtonContainer.classList.contains("floating-buttons-fab-fixed-wrapper")?(this.floatingButtonContainer.style.left=`${n}px`,this.floatingButtonContainer.style.transform="translateX(-50%)"):(this.floatingButtonContainer.style.left="50%",this.floatingButtonContainer.style.transform="translateX(-50%)")}))}updatePositionMode(){this.advancedSearchPanel&&this.floatingButtonContainer&&(this.advancedSearchPanelHeight=this._getNaturalPanelHeight(),queueMicrotask((()=>{const t=this.advancedSearchPanel.getBoundingClientRect();if(!(t.height>0&&"none"!==window.getComputedStyle(this.advancedSearchPanel).display&&"hidden"!==window.getComputedStyle(this.advancedSearchPanel).visibility))return void this._setFixedMode();const e=window.innerHeight-90,n=t.bottom>e;let i=this._currentMode;i=n?this._currentMode===PositionMode.SCROLLABLE&&t.bottome-50?PositionMode.FIXED:PositionMode.SCROLLABLE,i!==this._currentMode&&(i===PositionMode.FIXED?this._setFixedMode():this._setScrollableMode(),this._currentMode=i)})))}_getNaturalPanelHeight(){if(!this.advancedSearchPanel)return 0;const t=this.advancedSearchPanel.style.height;this.advancedSearchPanel.style.height="";const e=this.advancedSearchPanel.scrollHeight;return this.advancedSearchPanel.style.height=t,e}_setScrollableMode(){if(!this.floatingButtonContainer||!this.advancedSearchPanel)return;"static"===window.getComputedStyle(this.advancedSearchPanel).position&&(this._panelPositionWasStatic=!0,this.advancedSearchPanel.style.position="relative"),this.advancedSearchPanel.appendChild(this.floatingButtonContainer),this.floatingButtonContainer.classList.remove("floating-buttons-fab-fixed-wrapper"),this.floatingButtonContainer.classList.add("floating-buttons-fab-scrollable-wrapper"),this.advancedSearchPanelHeight=this._getNaturalPanelHeight(),this.advancedSearchPanelExtended=!1,this.advancedSearchPanel.classList.add("advancedSearchPanel--withBuffer"),this.advancedSearchPanel.style.paddingBottom=`${this._heightBuffer}px`}_setFixedMode(){if(!this.floatingButtonContainer)return;if(this.floatingButtonContainer.classList.contains("floating-buttons-fab-fixed-wrapper"))return;const t=this.advancedSearchPanel?this.advancedSearchPanel.getBoundingClientRect():null;if(t){const e=window.pageXOffset||document.documentElement.scrollLeft,n=t.left+t.width/2+e;this.floatingButtonContainer.style.left=`${n}px`,this.floatingButtonContainer.style.transform="translateX(-50%)"}this._panelPositionWasStatic&&this.advancedSearchPanel&&(this.advancedSearchPanel.style.position="",this._panelPositionWasStatic=!1);try{this._originalNextSibling&&this._originalNextSibling.parentNode===this._originalParent?this._originalParent.insertBefore(this.floatingButtonContainer,this._originalNextSibling):this._originalParent.appendChild(this.floatingButtonContainer)}catch(t){document.body.appendChild(this.floatingButtonContainer)}this.floatingButtonContainer.classList.remove("floating-buttons-fab-scrollable-wrapper"),this.floatingButtonContainer.classList.add("floating-buttons-fab-fixed-wrapper"),this.advancedSearchPanel&&(this.advancedSearchPanelExtended=!0,this.advancedSearchPanel.classList.remove("advancedSearchPanel--withBuffer"),this.advancedSearchPanel.style.height=this._savedPanelInlineHeight||"")}setFloatingMode(t){"scrollable"===t?this._setScrollableMode():this._setFixedMode()}show(){this.floatingButtonContainer&&(this.floatingButtonContainer.style.visibility="visible")}hide(){this.floatingButtonContainer&&(this.floatingButtonContainer.style.visibility="hidden")}toggleMenu(){queueMicrotask((()=>{this.menuOpen=!this.menuOpen;try{this.menuOpen?(this.fabMenu?.classList.add("open"),this.fabMenu&&this.fabMenu.setAttribute("aria-hidden","false"),this.menuToggleButton?.setAttribute("aria-expanded","true"),this.menuItems?.forEach((t=>{t.setAttribute("tabindex","0")})),this.menuItems?.[0]?.focus()):(this.fabMenu?.classList.remove("open"),this.fabMenu&&this.fabMenu.setAttribute("aria-hidden","true"),this.menuToggleButton?.setAttribute("aria-expanded","false"),this.menuItems?.forEach((t=>t.setAttribute("tabindex","-1"))))}catch{}}))}closeMenu(){this.menuOpen&&queueMicrotask((()=>{this.menuOpen=!1,this.fabMenu?.classList.remove("open"),this.fabMenu&&this.fabMenu.setAttribute("aria-hidden","true"),this.menuToggleButton?.setAttribute("aria-expanded","false"),this.menuItems?.forEach((t=>t.setAttribute("tabindex","-1")))}))}enableEditDeleteButtons(){document.getElementById("updateFilterButton")?.classList.remove("floating-buttons-disabled"),document.getElementById("deleteFilterButton")?.classList.remove("floating-buttons-disabled")}disableEditDeleteButtons(){document.getElementById("updateFilterButton")?.classList.add("floating-buttons-disabled"),document.getElementById("deleteFilterButton")?.classList.add("floating-buttons-disabled")}} diff --git a/public/js/dist/search-inputs.js b/public/js/dist/search-inputs.js new file mode 100644 index 000000000000..f756c199b606 --- /dev/null +++ b/public/js/dist/search-inputs.js @@ -0,0 +1 @@ +document.addEventListener("DOMContentLoaded",(function(){var e=new Map;$(document).on("select2:open",(function(t){if(!1!==t.target.classList.value.includes("expandOnFocus")){var i=t.target,n=$(i).next(".select2-container");if(n.length){if(!e.has(i.id)){var s=n.find(".select2-selection").outerHeight();e.set(i.id,s)}n.css({height:"auto","min-height":"75px"}),n.find(".select2-selection--multiple").css({height:"auto","min-height":"75px","max-height":"150","overflow-y":"auto"}),n.find(".select2-selection--single").css({height:"auto","min-height":"38px"})}}})),$(document).on("select2:close",(function(t){if(!1!==t.target.classList.value.includes("expandOnFocus")){var i=t.target,n=$(i).next(".select2-container");if(n.length){var s=e.get(i.id)||"38px";n.css({height:s,"min-height":""}),n.find(".select2-selection--multiple").css({height:s,"min-height":"","max-height":"","overflow-y":""}),n.find(".select2-selection--single").css({height:s,"min-height":""})}}}))})); diff --git a/public/js/dist/search-inputs.min.js b/public/js/dist/search-inputs.min.js new file mode 100644 index 000000000000..f756c199b606 --- /dev/null +++ b/public/js/dist/search-inputs.min.js @@ -0,0 +1 @@ +document.addEventListener("DOMContentLoaded",(function(){var e=new Map;$(document).on("select2:open",(function(t){if(!1!==t.target.classList.value.includes("expandOnFocus")){var i=t.target,n=$(i).next(".select2-container");if(n.length){if(!e.has(i.id)){var s=n.find(".select2-selection").outerHeight();e.set(i.id,s)}n.css({height:"auto","min-height":"75px"}),n.find(".select2-selection--multiple").css({height:"auto","min-height":"75px","max-height":"150","overflow-y":"auto"}),n.find(".select2-selection--single").css({height:"auto","min-height":"38px"})}}})),$(document).on("select2:close",(function(t){if(!1!==t.target.classList.value.includes("expandOnFocus")){var i=t.target,n=$(i).next(".select2-container");if(n.length){var s=e.get(i.id)||"38px";n.css({height:s,"min-height":""}),n.find(".select2-selection--multiple").css({height:s,"min-height":"","max-height":"","overflow-y":""}),n.find(".select2-selection--single").css({height:s,"min-height":""})}}}))})); diff --git a/public/js/dist/simpleDIContainer.js b/public/js/dist/simpleDIContainer.js new file mode 100644 index 000000000000..d86086d989bf --- /dev/null +++ b/public/js/dist/simpleDIContainer.js @@ -0,0 +1,48 @@ +class DIContainer { + constructor() { + this.services = new Map(); + this.singletons = new Map(); + } + + // Register a value or factory + register(name, definition, options = { singleton: true }) { + if (this.services.has(name)) { + throw new Error(`Service '${name}' is already registered.`); + } + + this.services.set(name, { definition, options }); + } + + // Resolve the service by name + resolve(name) { + const entry = this.services.get(name); + + if (!entry) { + throw new Error(`Service '${name}' is not registered.`); + } + + const { definition, options } = entry; + + // If it's a singleton and already created, return it + if (options.singleton && this.singletons.has(name)) { + return this.singletons.get(name); + } + + // If definition is a function, call it (lazy instantiation) + const instance = typeof definition === 'function' + ? definition(this) + : definition; + + if (options.singleton) { + this.singletons.set(name, instance); + } + + return instance; + } +} + +const container = new DIContainer(); +export { + container, + DIContainer +}; \ No newline at end of file diff --git a/public/js/dist/simpleDIContainer.min.js b/public/js/dist/simpleDIContainer.min.js new file mode 100644 index 000000000000..baa359eeb8ab --- /dev/null +++ b/public/js/dist/simpleDIContainer.min.js @@ -0,0 +1 @@ +class DIContainer{constructor(){this.services=new Map,this.singletons=new Map}register(e,s,t={singleton:!0}){if(this.services.has(e))throw new Error(`Service '${e}' is already registered.`);this.services.set(e,{definition:s,options:t})}resolve(e){const s=this.services.get(e);if(!s)throw new Error(`Service '${e}' is not registered.`);const{definition:t,options:n}=s;if(n.singleton&&this.singletons.has(e))return this.singletons.get(e);const i="function"==typeof t?t(this):t;return n.singleton&&this.singletons.set(e,i),i}}const container=new DIContainer;export{container,DIContainer}; diff --git a/public/mix-manifest.json b/public/mix-manifest.json index 62c7b01c142d..cf504fc35741 100644 --- a/public/mix-manifest.json +++ b/public/mix-manifest.json @@ -6,6 +6,30 @@ "/css/dist/all.css": "/css/dist/all.css?id=6b276946025b2c383b315d655683db02", "/css/dist/signature-pad.css": "/css/dist/signature-pad.css?id=6a89d3cd901305e66ced1cf5f13147f7", "/css/dist/signature-pad.min.css": "/css/dist/signature-pad.min.css?id=6a89d3cd901305e66ced1cf5f13147f7", + "/js/dist/simpleDIContainer.js": "/js/dist/simpleDIContainer.js?id=c1448e4da5e309ac1bb62a78363c44b2", + "/js/dist/simpleDIContainer.min.js": "/js/dist/simpleDIContainer.min.js?id=c1448e4da5e309ac1bb62a78363c44b2", + "/css/dist/modal.css": "/css/dist/modal.css?id=15a8ab15f4c980c27ad562349e6a4e95", + "/css/dist/modal.min.css": "/css/dist/modal.min.css?id=15a8ab15f4c980c27ad562349e6a4e95", + "/css/dist/advanced-search.css": "/css/dist/advanced-search.css?id=a60561c31389d30de119bd8eb116c7e0", + "/css/dist/advanced-search.min.css": "/css/dist/advanced-search.min.css?id=a60561c31389d30de119bd8eb116c7e0", + "/css/dist/advanced-search-index.css": "/css/dist/advanced-search-index.css?id=8839fb84019f3f1e28b614520b31b55a", + "/css/dist/advanced-search-index.min.css": "/css/dist/advanced-search-index.min.css?id=8839fb84019f3f1e28b614520b31b55a", + "/js/dist/floating-buttons.js": "/js/dist/floating-buttons.js?id=feb6e421491d8a59ec5c23916fc656d9", + "/js/dist/floating-buttons.min.js": "/js/dist/floating-buttons.min.js?id=feb6e421491d8a59ec5c23916fc656d9", + "/js/dist/apiService.js": "/js/dist/apiService.js?id=65c285125eaf9c56def5d9aa84c96177", + "/js/dist/apiService.min.js": "/js/dist/apiService.min.js?id=65c285125eaf9c56def5d9aa84c96177", + "/js/dist/filterInputs.js": "/js/dist/filterInputs.js?id=86850b08af3ebdbc9aa04755fd43b178", + "/js/dist/filterInputs.min.js": "/js/dist/filterInputs.min.js?id=86850b08af3ebdbc9aa04755fd43b178", + "/js/dist/filterFormManager.js": "/js/dist/filterFormManager.js?id=964dd02071d48c558c1b4215d7a87217", + "/js/dist/filterFormManager.min.js": "/js/dist/filterFormManager.min.js?id=964dd02071d48c558c1b4215d7a87217", + "/js/dist/filterUiController.js": "/js/dist/filterUiController.js?id=7b47cf53ed4ed258ef2a1bacbb58c4e1", + "/js/dist/filterUiController.min.js": "/js/dist/filterUiController.min.js?id=7b47cf53ed4ed258ef2a1bacbb58c4e1", + "/js/dist/search-inputs.js": "/js/dist/search-inputs.js?id=78b0044b471b5b2bdd5a7f1e399bf2da", + "/js/dist/search-inputs.min.js": "/js/dist/search-inputs.min.js?id=78b0044b471b5b2bdd5a7f1e399bf2da", + "/js/dist/advanced-search.js": "/js/dist/advanced-search.js?id=c726d7437541c0ea4b70539372e288f2", + "/js/dist/advanced-search.min.js": "/js/dist/advanced-search.min.js?id=c726d7437541c0ea4b70539372e288f2", + "/js/dist/advanced-search-index.js": "/js/dist/advanced-search-index.js?id=f678bcb27feda90b8920245d5ce1d359", + "/js/dist/advanced-search-index.min.js": "/js/dist/advanced-search-index.min.js?id=f678bcb27feda90b8920245d5ce1d359", "/js/select2/i18n/af.js": "/js/select2/i18n/af.js?id=4f6fcd73488ce79fae1b7a90aceaecde", "/js/select2/i18n/ar.js": "/js/select2/i18n/ar.js?id=65aa8e36bf5da57ff4e3f22a835ab035", "/js/select2/i18n/az.js": "/js/select2/i18n/az.js?id=270c257daf8140a0cf23ad5de6f8ed1b", diff --git a/resources/assets/css/components/advancedSearch/advanced-search-index.css b/resources/assets/css/components/advancedSearch/advanced-search-index.css new file mode 100644 index 000000000000..f832056f3083 --- /dev/null +++ b/resources/assets/css/components/advancedSearch/advanced-search-index.css @@ -0,0 +1,70 @@ + /* + Layout container for the whole page. + Uses flexbox so the filter and table sections can sit side by side on desktop, and stack on mobile. + */ + .responsive-layout { + display: flex; + flex-wrap: wrap; + width: 100%; + } + + /* + The filter (sidebar) section. + Transition allows smooth showing/hiding. + */ + .filter-section { + transition: all 0.3s ease; + } + + /* + When .hide is applied, the filter section is hidden. + !important ensures it's forced, even if overridden by other classes. + */ + .filter-section.hide { + display: none !important; + } + + /* ---------- DESKTOP Styles (screen ≥ 768px) ---------- */ + @media screen and (min-width: 768px) { + + /* + Filter sidebar gets 25% width, and some space on the right. + */ + .filter-section { + flex: 0 0 25%; + max-width: 25%; + padding-right: 15px; + } + + /* + Main table takes the remaining 75%. + */ + .table-section { + flex: 0 0 75%; + max-width: 75%; + } + + /* + If filter is hidden, the table takes full width. + */ + .filter-section.hide+.table-section { + flex: 0 0 100%; + max-width: 100%; + } + } + + /* ---------- MOBILE Styles (screen < 768px) ---------- */ + @media screen and (max-width: 767px) { + + /* + Filter takes full width, and sits above the table section. + */ + .filter-section { + width: 100%; + margin-bottom: 15px; + } + + .table-section { + width: 100%; + } + } \ No newline at end of file diff --git a/resources/assets/css/components/advancedSearch/advanced-search.css b/resources/assets/css/components/advancedSearch/advanced-search.css new file mode 100644 index 000000000000..df22604a1f00 --- /dev/null +++ b/resources/assets/css/components/advancedSearch/advanced-search.css @@ -0,0 +1,151 @@ +/* +Base styles for the filter sidebar and its transitions. +Handles showing/hiding the sidebar smoothly. +*/ +.filter-sidebar { + transition: all 0.3s ease; + position: relative; +} + +/* +Smooth transition for the filter body (the inner panel). +Ensures expanding/collapsing feels fluid. +*/ +.filter-body { + transition: all 0.3s ease; + overflow: hidden; +} + +/* +Fade-in/out effect for any element with this class when shown/hidden. +*/ +.filter-content { + transition: opacity 0.2s ease; +} + +/* +Fade-in/out for filter section title and clear text. +*/ +.filter-title, +.clear-text { + transition: opacity 0.2s ease; +} + +/* ---------- Desktop styles ---------- */ +@media (min-width: 769px) { + /* + When collapsed, the sidebar is skinny and its content is hidden. + */ +} + +/* ---------- Mobile/Tablet styles ---------- */ +@media (max-width: 768px) { + /* + Sidebar takes full width and less margin on mobile. + */ + .filter-sidebar { + width: 100% !important; + margin-bottom: 15px; + } + + /* + Collapsed sidebar hides content and disables interaction. + */ +} + +/* +Ensures filter panel container uses full width and is padded. +Makes it responsive. +*/ +.container { + width: 100%; + margin: 0 auto; + padding: 10px; + box-sizing: border-box; +} + +/* +Makes the advanced search filters section block-level and full width. +*/ +#advanced-search-filters { + display: block; + max-width: 100%; + margin: 0; +} + +.advanced-search-panel-with-buffer { + min-height: calc(100% + 70px); + padding-bottom: 70px; /* gives room for floating buttons */ +} + +/* +Scrollable filter panel body, with padding. +*/ +.box-body { + overflow-y: auto; + padding: 15px; +} + +/* +On small screens, limit box-body max height to 75% of viewport. +Makes scrolling manageable on mobile. +*/ +@media screen and (max-width: 768px) { + .box-body { + max-height: 75vh; + } +} + +/* +On desktop, take up all available vertical height. +*/ +@media screen and (min-width: 769px) { + .box-body { + height: 100%; + } +} + +/* +Button blocks have a little space between each for clarity. +*/ + +/* +Custom thin scrollbar for the filter area. +Aesthetic tweak. +*/ +.box-body::-webkit-scrollbar { + width: 6px; +} + +/* +Collapse button tweaks for spacing. +*/ +.collapse-toggle { + margin-right: 5px; +} + +/* +By default, hide both the desktop and mobile collapse icons. +*/ +.icon-desktop, +.icon-mobile { + display: none; +} + +/* +Show the desktop collapse icon on desktop screens. +*/ +@media screen and (min-width: 768px) { + .icon-desktop { + display: inline; + } +} + +/* +Show the mobile collapse icon on mobile screens. +*/ +@media screen and (max-width: 767px) { + .icon-mobile { + display: inline; + } +} diff --git a/resources/assets/css/components/advancedSearch/filterInputs.css b/resources/assets/css/components/advancedSearch/filterInputs.css new file mode 100644 index 000000000000..bc28b407d9b8 --- /dev/null +++ b/resources/assets/css/components/advancedSearch/filterInputs.css @@ -0,0 +1,503 @@ +/* +Layout container for the whole page. +Uses flexbox so the filter and table sections can sit side by side on desktop, and stack on mobile. +*/ +.responsive-layout { + display: flex; + flex-wrap: wrap; + width: 100%; +} + +/* +The filter (sidebar) section. +Transition allows smooth showing/hiding. +*/ +.filter-section { + transition: all 0.3s ease; +} + +/* +When .hide is applied, the filter section is hidden. +!important ensures it's forced, even if overridden by other classes. +*/ +.filter-section.hide { + display: none !important; +} + +/* New CSS for the advanced search layout */ +.advanced-search-wrapper { + width: 100%; + padding: 0 15px; +} + +/* +Space between each filter item +*/ +.advanced-search-grid-container { + display: flex; + flex-direction: column; + gap: 20px; +} + +.advanced-search-item-container { + display: flex; + flex-direction: column; + gap: 8px; +} + +/* Heading takes full width on its own row */ +.filter-field-name { + font-weight: 600; + font-size: 14px; + margin: 0; +} + +/* Container for operator dropdown + input */ +.filter-controls-row { + display: flex; + gap: 0; + width: 100%; +} + +/* Operator dropdown - smaller width */ +.filter-option { + flex: 0 0 60px; + /* Fixed width for consistency */ + min-width: 35px; + max-width: 42px; + height: 38px; + padding: 0 2px; + font-size: 13px; + border: 1px solid var(--text-main); + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-right: none; +} + +/* Input field - takes remaining space */ +.advanced-search-default-field, +.advanced-search-grid-container .form-control:not(.filter-option), +.select2-container { + flex: 1; + height: 38px; + font-size: 13px; + border: 1px solid var(--text-main); + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.advanced-search-default-field, +.advanced-search-grid-container .form-control:not(.filter-option) { + padding: 6px 10px; +} + +/* Select2 specific styling */ +.select2-container { + width: auto; + max-width: width; + box-sizing: border-box; + background-color: darkblue; +} + +.select2-container--default .select2-selection--single, +.select2-container--default .select2-selection--multiple { + height: 38px; + border: 1px solid var(--button-hover); + border-top-left-radius: 0; + border-bottom-left-radius: 0; + border-left: none; +} + +.select2-container--default .select2-selection--single .select2-selection__rendered { + line-height: 36px; + padding-left: 10px; +} + +.select2-search--dropdown{ + background-color: var(--hover-link, #eee); +} + +.select2-results { + background-color: var(--button-main); +} + + +/* Focus states */ +.filter-option:focus, +.advanced-search-default-field:focus, +.form-control:focus { + border-color: var(--button-hover); + outline: none; +} + +.select2-container--default .select2-selection:focus { + border-color: var(--button-hover); + outline: none; +} + +/* Date range styling */ +.input-daterange { + display: flex; + align-items: center; + flex: 1; +} + +.input-daterange .form-control { + border-radius: 0; + border-left: none; +} + +.input-daterange .form-control:first-child { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.input-daterange .form-control:last-child { + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; +} + +.input-group-addon { + display: flex; + align-items: center; + justify-content: center; + padding: 11px; +} + +.filter-option { + /* for Firefox */ + -moz-appearance: none; + /* for Safari, Chrome, Opera */ + -webkit-appearance: none; + appearance: none; +} + +/* ---------- DESKTOP Styles (screen ≥ 768px) ---------- */ +@media screen and (min-width: 768px) { + + /* + Filter sidebar gets 25% width, and some space on the right. + */ + .filter-section { + flex: 0 0 25%; + max-width: 25%; + padding-right: 15px; + } + + /* + Main table takes the remaining 75%. + */ + .table-section { + flex: 0 0 75%; + max-width: 75%; + } + + /* + If filter is hidden, the table takes full width. + */ + .filter-section.hide+.table-section { + flex: 0 0 100%; + max-width: 100%; + } +} + +/* ---------- MOBILE Styles (screen < 768px) ---------- */ +@media screen and (max-width: 767px) { + + /* + Filter takes full width, and sits above the table section. + */ + .filter-section { + width: 100%; + margin-bottom: 15px; + } + + .table-section { + width: 100%; + } + + /* Adjust operator dropdown width on mobile */ + .filter-option { + flex: 0 0 120px; + min-width: 120px; + } +} + + +/* Add this to your existing @stack('css') @@ -1469,6 +1475,14 @@ @endif + @if(Gate::allows('view', App\Models\predefinedFilters::class) || Gate::allows('view', App\Models\CustomFieldset::class)) +
  • is('predefined-filters*') ? ' class="active"' : '') !!}> + + {{ trans('admin/predefinedFilters/general.predefined_filter') }} + +
  • + @endif + @can('view', \App\Models\Statuslabel::class)
  • is('statuslabels*') ? ' class="active"' : '') !!}> diff --git a/resources/views/livewire/partials/advancedsearch/modal.blade.php b/resources/views/livewire/partials/advancedsearch/modal.blade.php new file mode 100644 index 000000000000..2b397bf2a9e5 --- /dev/null +++ b/resources/views/livewire/partials/advancedsearch/modal.blade.php @@ -0,0 +1,239 @@ + + @push('css') + + @endpush + @if ($showModal) + {{-- CSS --}} + + + + {{-- Javascript --}} + @script + + @endscript + @endif + diff --git a/resources/views/partials/advanced-search/advanced-search-translations.blade.php b/resources/views/partials/advanced-search/advanced-search-translations.blade.php new file mode 100644 index 000000000000..099769fbc5ad --- /dev/null +++ b/resources/views/partials/advanced-search/advanced-search-translations.blade.php @@ -0,0 +1,12 @@ + diff --git a/resources/views/partials/advanced-search/advanced-search.blade.php b/resources/views/partials/advanced-search/advanced-search.blade.php new file mode 100644 index 000000000000..542e55b621c6 --- /dev/null +++ b/resources/views/partials/advanced-search/advanced-search.blade.php @@ -0,0 +1,68 @@ +
    + @push('css') + + @endpush +
    +

    + {{ trans('general.advanced_search') }} +

    +
    + + +
    +
    + +
    + +
    + + +
    + + +
    + @include ('partials.select.dropdowns.predefined-select', [ + 'translated_name' => trans('general.select_predefined_filter'), + 'fieldname' => 'predefinedFilters', + 'select_id' => "predefinedfilters-select", + 'required' => 'false', + ]) +
    + +
    + + +
    + @php + $layoutJson = \App\Presenters\AssetPresenter::dataTableLayout(); + $layout = json_decode($layoutJson); + @endphp + + @include('partials.advanced-search.search-inputs') +
    + + +
    + @include ('partials.advanced-search.floating-button') + +
    + +@include ('partials.advanced-search.advanced-search-translations') +@include('partials.confetti-js', ['autostart' => false]) + + diff --git a/resources/views/partials/advanced-search/floating-button.blade.php b/resources/views/partials/advanced-search/floating-button.blade.php new file mode 100644 index 000000000000..52f97716181e --- /dev/null +++ b/resources/views/partials/advanced-search/floating-button.blade.php @@ -0,0 +1,19 @@ +
    diff --git a/resources/views/partials/advanced-search/search-inputs.blade.php b/resources/views/partials/advanced-search/search-inputs.blade.php new file mode 100644 index 000000000000..ec2a395f80b2 --- /dev/null +++ b/resources/views/partials/advanced-search/search-inputs.blade.php @@ -0,0 +1,162 @@ + + + @foreach ($layout as $tableField) + @if ((!empty($tableField->searchable) && $tableField->searchable === true)) + + + +
    + + + + @if (!isset($tableField->formatter)) + {{-- Default select if formatter is not set --}} + + @else + @switch($tableField->formatter) + @case('dateDisplayFormatter') +
    + +
    {{ strtolower(trans('general.to')) }}
    + +
    + @break + @case('companiesLinkObjFormatter') + @include ('partials.select.dropdowns.company-select', [ + 'translated_name' => trans('admin/hardware/company.model'), + 'fieldname' => $tableField->field, + 'select_id' => "advancedSearch_$tableField->field", + 'required' => 'false', + 'multiple' => 'true', + 'allow_tags' => 'true', + ]) + @break + @case('trueFalseFormatter') +

    True/false

    + @break + @case('categoriesLinkObjFormatter') + @include ('partials.select.dropdowns.category-select', [ + 'translated_name' => trans('admin/hardware/category.model'), + 'fieldname' => $tableField->field, + 'category_type' => 'asset', + 'select_id' => "advancedSearch_$tableField->field", + 'required' => 'false', + 'multiple' => 'true', + 'allow_tags' => 'true', + ]) + @break + @case('companiesLinkObjFormatter') +

    companiesLinkObjFormatter

    + @break + @case('deployedLocationFormatter') + @include ('partials.select.dropdowns.location-select', [ + 'translated_name' => trans('admin/hardware/location.model'), + 'category_type' => 'asset', + 'select_id' => "advancedSearch_$tableField->field", + 'fieldname' => $tableField->field, + 'required' => 'false', + 'multiple' => 'true', + 'allow_tags' => 'true', + ]) + @break + @case('employeeNumFormatter') + + @break + @case('hardwareLinkFormatter') + + @break + + @case('manufacturersLinkObjFormatter') + @include ('partials.select.dropdowns.manufacturer-select', [ + 'translated_name' => trans('admin/hardware/manufacturer.model'), + 'select_id' => "advancedSearch_$tableField->field", + 'fieldname' => $tableField->field, + 'required' => 'false', + 'multiple' => 'true', + 'allow_tags' => 'true', + ]) + @break + @case('modelsLinkObjFormatter') + @include ('partials.select.dropdowns.model-select', [ + 'translated_name' => trans('admin/hardware/form.model'), + 'select_id' => "advancedSearch_$tableField->field", + 'fieldname' => $tableField->field, + 'required' => 'false', + 'multiple' => 'true', + 'allow_tags' => 'true', + ]) + @break + @case('orderNumberObjFilterFormatter') + + @break + @case('polymorphicItemFormatter') + + + + + @break + @case('statuslabelsLinkObjFormatter') + @include ('partials.select.dropdowns.status-select', [ + 'translated_name' => trans('admin/hardware/status.model'), + 'select_id' => "advancedSearch_$tableField->field", + 'fieldname' => $tableField->field, + 'required' => 'false', + 'multiple' => 'true', + 'allow_tags' => 'true', + ]) + @break + @case('suppliersLinkObjFormatter') + @include ('partials.select.dropdowns.supplier-select', [ + 'translated_name' => trans('admin/hardware/supplier.model'), + 'select_id' => "advancedSearch_$tableField->field", + 'fieldname' => $tableField->field, + 'required' => 'false', + 'multiple' => 'true', + 'allow_tags' => 'true', + ]) + @break + @case('trueFalseFormatter') +

    trueFalseFormatter

    + @break + @case('customFieldsFormatter') + + @break + @case('usersLinkObjFormatter') + @include ('partials.select.dropdowns.user-select', [ + 'translated_name' => trans('admin/hardware/user.model'), + 'select_id' => "advancedSearch_$tableField->field", + 'fieldname' => $tableField->field, + 'required' => 'false', + 'multiple' => 'true', + 'allow_tags' => 'true', + ]) + @break + @default + + @endswitch + @endif +
    +
    + @endif + @endforeach +
    +
    + + \ No newline at end of file diff --git a/resources/views/partials/asset-bulk-actions.blade.php b/resources/views/partials/asset-bulk-actions.blade.php index d66170cc5b3e..d7e56956c694 100644 --- a/resources/views/partials/asset-bulk-actions.blade.php +++ b/resources/views/partials/asset-bulk-actions.blade.php @@ -43,5 +43,13 @@ class="form-inline" + + @if(isset($showFiltersTogglebutton) && $showFiltersTogglebutton === true) + + + @endif diff --git a/resources/views/partials/bootstrap-table.blade.php b/resources/views/partials/bootstrap-table.blade.php index 2d4df19a5285..1783860c570e 100644 --- a/resources/views/partials/bootstrap-table.blade.php +++ b/resources/views/partials/bootstrap-table.blade.php @@ -1305,6 +1305,7 @@ function assetRequestActionsFormatter (row, value) { 'groups', 'hardware', 'kits', + 'predefined-filters', 'licenses', 'locations', 'maintenances', @@ -1516,6 +1517,13 @@ function assetSerialLinkFormatter(value, row) { return ''; } + // this Links to '/hardware' with the filter id + function predefinedFiltersLinkFormatter(value, row){ + if (value && row.id){ + return `${value}`; + } + } + function trueFalseFormatter(value) { if ((value) && ((value == 'true') || (value == '1'))) { return '{{ trans('general.true') }}'; diff --git a/resources/views/partials/confetti-js.blade.php b/resources/views/partials/confetti-js.blade.php index 733d96d1b40e..964797c93905 100644 --- a/resources/views/partials/confetti-js.blade.php +++ b/resources/views/partials/confetti-js.blade.php @@ -1,25 +1,45 @@ -@if (auth()->user() && auth()->user()->enable_confetti=='1') +@if (auth()->user() && auth()->user()->enable_confetti == '1') @endif \ No newline at end of file diff --git a/resources/views/partials/forms/edit/accessory-select.blade.php b/resources/views/partials/forms/edit/accessory-select.blade.php index e047d16d2f0d..6ecf4f2b236a 100644 --- a/resources/views/partials/forms/edit/accessory-select.blade.php +++ b/resources/views/partials/forms/edit/accessory-select.blade.php @@ -2,18 +2,7 @@
    - + @include('partials.select/dropdowns/accessory-select')
    {!! $errors->first($fieldname, '
    :message
    ') !!} diff --git a/resources/views/partials/forms/edit/asset-select.blade.php b/resources/views/partials/forms/edit/asset-select.blade.php index 9b7caa85f1f4..a5e6fe565a95 100644 --- a/resources/views/partials/forms/edit/asset-select.blade.php +++ b/resources/views/partials/forms/edit/asset-select.blade.php @@ -3,38 +3,7 @@ class="form-group{{ $errors->has($fieldname) ? ' has-error' : '' }}"{!! (isset($style)) ? ' style="'.e($style).'"' : '' !!}>
    - + @include('partials.select/dropdowns/asset-select')
    {!! $errors->first($fieldname, '
    ') !!} diff --git a/resources/views/partials/forms/edit/category-select.blade.php b/resources/views/partials/forms/edit/category-select.blade.php index db85cfd9b69f..8d053497fce8 100644 --- a/resources/views/partials/forms/edit/category-select.blade.php +++ b/resources/views/partials/forms/edit/category-select.blade.php @@ -4,26 +4,7 @@
    - + @include('partials.select/dropdowns/category-select')
    @can('create', \App\Models\Category::class) diff --git a/resources/views/partials/forms/edit/company-select.blade.php b/resources/views/partials/forms/edit/company-select.blade.php index d4ce87b06e15..d290d99a5f6b 100644 --- a/resources/views/partials/forms/edit/company-select.blade.php +++ b/resources/views/partials/forms/edit/company-select.blade.php @@ -4,15 +4,14 @@
    - + @include('partials.select/dropdowns/company-select', [ + 'fieldname' => $fieldname, + 'translated_name' => $translated_name, + 'item' => $item ?? null, + 'multiple' => $multiple ?? 'false', + 'selected' => $selected ?? null, + 'disabled' => true + ])
    @@ -21,22 +20,14 @@
    - + @include('partials.select/dropdowns/company-select', [ + 'fieldname' => $fieldname, + 'translated_name' => $translated_name, + 'item' => $item ?? null, + 'multiple' => $multiple ?? 'false', + 'selected' => $selected ?? null, + 'disabled' => false + ])
    {!! $errors->first($fieldname, '
    :message
    ') !!} diff --git a/resources/views/partials/forms/edit/consumable-select.blade.php b/resources/views/partials/forms/edit/consumable-select.blade.php index ccc8030a77ed..e0628ace2020 100644 --- a/resources/views/partials/forms/edit/consumable-select.blade.php +++ b/resources/views/partials/forms/edit/consumable-select.blade.php @@ -1,19 +1,8 @@
    -
    - +
    + @include('partials.select/dropdowns/consumable-select')
    {!! $errors->first($fieldname, '
    :message
    ') !!} diff --git a/resources/views/partials/forms/edit/department-select.blade.php b/resources/views/partials/forms/edit/department-select.blade.php index 0648f991dac4..799638b641c6 100644 --- a/resources/views/partials/forms/edit/department-select.blade.php +++ b/resources/views/partials/forms/edit/department-select.blade.php @@ -3,25 +3,7 @@
    - + @include('partials.select/dropdowns/department-select')
    diff --git a/resources/views/partials/forms/edit/kit-select.blade.php b/resources/views/partials/forms/edit/kit-select.blade.php index 27c0800a9227..49f99f7798f2 100644 --- a/resources/views/partials/forms/edit/kit-select.blade.php +++ b/resources/views/partials/forms/edit/kit-select.blade.php @@ -3,15 +3,7 @@
    - + @include('partials.select/dropdowns/kit-select')
    diff --git a/resources/views/partials/forms/edit/license-select.blade.php b/resources/views/partials/forms/edit/license-select.blade.php index e6b8a8d7c2cf..7e1574a7d062 100644 --- a/resources/views/partials/forms/edit/license-select.blade.php +++ b/resources/views/partials/forms/edit/license-select.blade.php @@ -2,18 +2,7 @@
    - + @include('partials.select/dropdowns/license-select')
    {!! $errors->first($fieldname, '
    :message
    ') !!} diff --git a/resources/views/partials/forms/edit/location-profile-select.blade.php b/resources/views/partials/forms/edit/location-profile-select.blade.php index f3db9ad9b727..3c24e80975bc 100644 --- a/resources/views/partials/forms/edit/location-profile-select.blade.php +++ b/resources/views/partials/forms/edit/location-profile-select.blade.php @@ -1,17 +1,9 @@ -
    +
    - + @include('partials.select/dropdowns/location-profile-select')
    {!! $errors->first('location_id', '
    ') !!} diff --git a/resources/views/partials/forms/edit/location-select.blade.php b/resources/views/partials/forms/edit/location-select.blade.php index 2242a1c2b730..7185f3636c64 100644 --- a/resources/views/partials/forms/edit/location-select.blade.php +++ b/resources/views/partials/forms/edit/location-select.blade.php @@ -3,20 +3,7 @@
    - + @include('partials.select/dropdowns/location-select')
    @@ -49,9 +36,8 @@
    - @endif +@endif
    - diff --git a/resources/views/partials/forms/edit/manufacturer-select.blade.php b/resources/views/partials/forms/edit/manufacturer-select.blade.php index faad59d306e4..5f8dedd1d666 100644 --- a/resources/views/partials/forms/edit/manufacturer-select.blade.php +++ b/resources/views/partials/forms/edit/manufacturer-select.blade.php @@ -4,28 +4,7 @@
    - + @include('partials.select/dropdowns/manufacturer-select')
    diff --git a/resources/views/partials/forms/edit/model-select.blade.php b/resources/views/partials/forms/edit/model-select.blade.php index 77518acdefe4..406a28f18566 100644 --- a/resources/views/partials/forms/edit/model-select.blade.php +++ b/resources/views/partials/forms/edit/model-select.blade.php @@ -4,26 +4,7 @@
    - + @include('partials.select/dropdowns/model-select')
    @can('create', \App\Models\AssetModel::class) diff --git a/resources/views/partials/forms/edit/status-select.blade.php b/resources/views/partials/forms/edit/status-select.blade.php index ced8e0498b6d..3a761c60617e 100644 --- a/resources/views/partials/forms/edit/status-select.blade.php +++ b/resources/views/partials/forms/edit/status-select.blade.php @@ -4,21 +4,7 @@
    - + @include('partials.select/dropdowns/status-select')
    diff --git a/resources/views/partials/forms/edit/supplier-select.blade.php b/resources/views/partials/forms/edit/supplier-select.blade.php index ba1fdde815a3..85738e3aceb8 100644 --- a/resources/views/partials/forms/edit/supplier-select.blade.php +++ b/resources/views/partials/forms/edit/supplier-select.blade.php @@ -3,20 +3,7 @@
    - + @include('partials.select/dropdowns/supplier-select')
    diff --git a/resources/views/partials/forms/edit/user-select.blade.php b/resources/views/partials/forms/edit/user-select.blade.php index 590be3c38c00..772ad78301a6 100644 --- a/resources/views/partials/forms/edit/user-select.blade.php +++ b/resources/views/partials/forms/edit/user-select.blade.php @@ -3,15 +3,7 @@
    - + @include('partials.select/dropdowns/user-select')
    diff --git a/resources/views/partials/select/dropdowns/accessory-select.blade.php b/resources/views/partials/select/dropdowns/accessory-select.blade.php new file mode 100644 index 000000000000..f5ca9af90abd --- /dev/null +++ b/resources/views/partials/select/dropdowns/accessory-select.blade.php @@ -0,0 +1,14 @@ + + + \ No newline at end of file diff --git a/resources/views/partials/select/dropdowns/asset-select.blade.php b/resources/views/partials/select/dropdowns/asset-select.blade.php new file mode 100644 index 000000000000..fdce59fd7936 --- /dev/null +++ b/resources/views/partials/select/dropdowns/asset-select.blade.php @@ -0,0 +1,32 @@ + \ No newline at end of file diff --git a/resources/views/partials/select/dropdowns/category-select.blade.php b/resources/views/partials/select/dropdowns/category-select.blade.php new file mode 100644 index 000000000000..48f490c905b6 --- /dev/null +++ b/resources/views/partials/select/dropdowns/category-select.blade.php @@ -0,0 +1,24 @@ + diff --git a/resources/views/partials/select/dropdowns/company-select.blade.php b/resources/views/partials/select/dropdowns/company-select.blade.php new file mode 100644 index 000000000000..74f6b36fd3de --- /dev/null +++ b/resources/views/partials/select/dropdowns/company-select.blade.php @@ -0,0 +1,28 @@ + diff --git a/resources/views/partials/select/dropdowns/consumable-select.blade.php b/resources/views/partials/select/dropdowns/consumable-select.blade.php new file mode 100644 index 000000000000..8530b6e68eaa --- /dev/null +++ b/resources/views/partials/select/dropdowns/consumable-select.blade.php @@ -0,0 +1,12 @@ + \ No newline at end of file diff --git a/resources/views/partials/select/dropdowns/department-select.blade.php b/resources/views/partials/select/dropdowns/department-select.blade.php new file mode 100644 index 000000000000..0214b7eef9ac --- /dev/null +++ b/resources/views/partials/select/dropdowns/department-select.blade.php @@ -0,0 +1,19 @@ + \ No newline at end of file diff --git a/resources/views/partials/select/dropdowns/group-select.blade.php b/resources/views/partials/select/dropdowns/group-select.blade.php new file mode 100644 index 000000000000..9e47e4782d8d --- /dev/null +++ b/resources/views/partials/select/dropdowns/group-select.blade.php @@ -0,0 +1,39 @@ + \ No newline at end of file diff --git a/resources/views/partials/select/dropdowns/kit-select.blade.php b/resources/views/partials/select/dropdowns/kit-select.blade.php new file mode 100644 index 000000000000..c1c9f188722d --- /dev/null +++ b/resources/views/partials/select/dropdowns/kit-select.blade.php @@ -0,0 +1,9 @@ + \ No newline at end of file diff --git a/resources/views/partials/select/dropdowns/license-select.blade.php b/resources/views/partials/select/dropdowns/license-select.blade.php new file mode 100644 index 000000000000..040effb74a61 --- /dev/null +++ b/resources/views/partials/select/dropdowns/license-select.blade.php @@ -0,0 +1,12 @@ + \ No newline at end of file diff --git a/resources/views/partials/select/dropdowns/location-profile-select.blade.php b/resources/views/partials/select/dropdowns/location-profile-select.blade.php new file mode 100644 index 000000000000..5ad82d9a75b6 --- /dev/null +++ b/resources/views/partials/select/dropdowns/location-profile-select.blade.php @@ -0,0 +1,29 @@ +{{-- Some pages won't set the $fieldname. If this is the case the fallback-value of "default_location_profile_select" is used. --}} + diff --git a/resources/views/partials/select/dropdowns/location-select.blade.php b/resources/views/partials/select/dropdowns/location-select.blade.php new file mode 100644 index 000000000000..c3b7d3f47fcc --- /dev/null +++ b/resources/views/partials/select/dropdowns/location-select.blade.php @@ -0,0 +1,17 @@ + diff --git a/resources/views/partials/select/dropdowns/manufacturer-select.blade.php b/resources/views/partials/select/dropdowns/manufacturer-select.blade.php new file mode 100644 index 000000000000..229b847a5994 --- /dev/null +++ b/resources/views/partials/select/dropdowns/manufacturer-select.blade.php @@ -0,0 +1,26 @@ + \ No newline at end of file diff --git a/resources/views/partials/select/dropdowns/model-select.blade.php b/resources/views/partials/select/dropdowns/model-select.blade.php new file mode 100644 index 000000000000..61ac70597a5f --- /dev/null +++ b/resources/views/partials/select/dropdowns/model-select.blade.php @@ -0,0 +1,26 @@ + \ No newline at end of file diff --git a/resources/views/partials/select/dropdowns/predefined-select.blade.php b/resources/views/partials/select/dropdowns/predefined-select.blade.php new file mode 100644 index 000000000000..ec8e75d4b2fd --- /dev/null +++ b/resources/views/partials/select/dropdowns/predefined-select.blade.php @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/resources/views/partials/select/dropdowns/status-select.blade.php b/resources/views/partials/select/dropdowns/status-select.blade.php new file mode 100644 index 000000000000..527d8cbf5874 --- /dev/null +++ b/resources/views/partials/select/dropdowns/status-select.blade.php @@ -0,0 +1,17 @@ + \ No newline at end of file diff --git a/resources/views/partials/select/dropdowns/supplier-select.blade.php b/resources/views/partials/select/dropdowns/supplier-select.blade.php new file mode 100644 index 000000000000..8e394749e974 --- /dev/null +++ b/resources/views/partials/select/dropdowns/supplier-select.blade.php @@ -0,0 +1,16 @@ + \ No newline at end of file diff --git a/resources/views/partials/select/dropdowns/user-select.blade.php b/resources/views/partials/select/dropdowns/user-select.blade.php new file mode 100644 index 000000000000..23c7feb2bbf6 --- /dev/null +++ b/resources/views/partials/select/dropdowns/user-select.blade.php @@ -0,0 +1,11 @@ + \ No newline at end of file diff --git a/resources/views/predefined-filters/index.blade.php b/resources/views/predefined-filters/index.blade.php new file mode 100644 index 000000000000..75edea45fa79 --- /dev/null +++ b/resources/views/predefined-filters/index.blade.php @@ -0,0 +1,122 @@ +@php + use App\Presenters\PredefinedFilterPresenter; +@endphp + +@extends('layouts/default') + +{{-- Page title --}} +@section('title') +{{ trans('admin/predefinedFilters/table.title') }} +@parent +@stop + +{{-- Page content --}} +@section('content') + +
    +
    +
    +
    + +
    +
    +
    +
    + +
    +

    {{ trans('admin/predefinedFilters/table.about') }}

    + +
    +
    +

    {!! trans('admin/predefinedFilters/table.info') !!}

    +
    +
    + +
    +
    +

    {{ trans('admin/predefinedFilters/table.private') }}: {{ trans('admin/predefinedFilters/help.private') }}

    +
    +
    + +
    +
    +

    {{ trans('admin/predefinedFilters/table.public') }}: {{ trans('admin/predefinedFilters/help.public') }}

    +
    +
    + +
    + +
    +@stop + +@section('moar_scripts') +@include ('partials.bootstrap-table') + + + + +@php + $layout = json_decode(PredefinedFilterPresenter::dataTableLayout()); +@endphp +@stop diff --git a/routes/api.php b/routes/api.php index dbb9348f7e4a..7a07764a0d4b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,6 +1,7 @@ group(function () { + + Route::middleware('auth:api')->prefix('predefinedFilters')->group(function () { + Route::get('/', [PredefinedFilterController::class, 'index']) + ->name('api.predefined-filters.index'); + + Route::get('/selectlist', [PredefinedFilterController::class, 'selectlist']) + ->name('api.predefined-filters.selectlist'); + + Route::get('/{id}', [PredefinedFilterController::class, 'show']) + ->name('api.predefined-filters.show'); + + Route::post('/', [PredefinedFilterController::class, 'store']) + ->name('api.predefined-filters.store'); + + Route::put('/{id}', [PredefinedFilterController::class, 'update']) + ->name('api.predefined-filters.update'); + + Route::put('predefinedFilters/{id}/sync-permissions', [PredefinedFilterController::class, 'syncPermissionGroups']) + ->name('api.predefined-filters.sync-permissions'); + + Route::delete('/{id}', [PredefinedFilterController::class, 'destroy']) + ->name('api.predefined-filters.destroy'); + }); // end predefinedFilters API routes + /** * Settings API routes */ @@ -1377,4 +1406,4 @@ )->name('api.files.destroy') ->where(['object_type' => 'accessories|assets|components|consumables|hardware|licenses|locations|maintenances|models|suppliers|users']); -}); // end API routes +}); // end API routes \ No newline at end of file diff --git a/routes/web/predefined-filters.php b/routes/web/predefined-filters.php new file mode 100644 index 000000000000..16272c8f9ea3 --- /dev/null +++ b/routes/web/predefined-filters.php @@ -0,0 +1,12 @@ +group(function () { + Route::get('predefined-filters', [PredefinedFilterController::class, 'index'])->name('predefined-filters.index'); + Route::get('predefined-filters/{filter}', [PredefinedFilterController::class,'view']) + ->name('predefined-filters.view'); + Route::delete('predefined-filters/{id}', [PredefinedFilterController::class, 'destroy'])->name('predefined-filters.destroy'); +}); diff --git a/storage/app/backups/env-backups/.gitignore b/storage/app/backups/env-backups/.gitignore old mode 100755 new mode 100644 diff --git a/storage/framework/views/.gitignore b/storage/framework/views/.gitignore old mode 100755 new mode 100644 diff --git a/storage/private_uploads/assetmodels/assetmodels b/storage/private_uploads/assetmodels/assetmodels new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/storage/private_uploads/maintenances/.gitignore b/storage/private_uploads/maintenances/.gitignore old mode 100755 new mode 100644 diff --git a/storage/private_uploads/models/.gitignore b/storage/private_uploads/models/.gitignore old mode 100755 new mode 100644 diff --git a/tests/Feature/AssetQuery/Api/AssignedToQueryTest.php b/tests/Feature/AssetQuery/Api/AssignedToQueryTest.php new file mode 100644 index 000000000000..16adc1ce651f --- /dev/null +++ b/tests/Feature/AssetQuery/Api/AssignedToQueryTest.php @@ -0,0 +1,243 @@ +actingAsForApi(User::factory()->superuser()->create())->getJson( + route('api.assets.index', [ + 'status' => '', + 'order_number' => '', + 'company_id' => '', + 'status_id' => '', + 'filter' => json_encode($filter), + 'search' => '', + 'sort' => 'id', + 'order' => 'asc', + 'offset' => '0', + 'limit' => '50', + ]) + ); + } + + public function testFilterAssetsEmptyValue(): void + { + $parentAssetA = Asset::factory()->create(['asset_tag' => 'pc01', 'name' => 'Server']); + $parentAssetB = Asset::factory()->create(['asset_tag' => 'srv01', 'name' => 'Desktop']); + + $assetA = Asset::factory()->create(['assigned_type' => Asset::class, 'assigned_to' => $parentAssetA->id]); + $assetB = Asset::factory()->create(['assigned_type' => Asset::class, 'assigned_to' => $parentAssetB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => Asset::class, + 'value' => '' + ], + 'operator' => 'contains', + 'logic' => 'AND' + ], + ]; + + $this->getFilteredAssets($filter) + ->assertOk() + ->assertJsonStructure([ + 'total', + 'rows', + ]) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 4)->etc()) + ->assertJsonFragment([ + 'id' => $assetA->id, + ]) + ->assertJsonFragment([ + 'id' => $assetB->id, + ]) + ->assertJsonFragment([ + 'id' => $parentAssetA->id, + ]) + ->assertJsonFragment([ + 'id' => $parentAssetB->id, + ]); + } + + public function testFilterAssetsInvalidType(): void + { + $locationA = Location::factory()->create(['name' => 'Stockholm']); + $locationB = Location::factory()->create(['name' => 'Copenhagen']); + + $assetA = Asset::factory()->create(['assigned_type' => Location::class, 'assigned_to' => $locationA->id]); + $assetB = Asset::factory()->create(['assigned_type' => Location::class, 'assigned_to' => $locationB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => 'invalid', + 'value' => '' + ], + 'operator' => 'contains', + 'logic' => 'AND' + ], + ]; + + // Expect the API to return a server error for an invalid type + $this->getFilteredAssets($filter) + ->assertServerError(); + } + + public function testFilterAssetsUserContainsValue(): void + { + $userA = User::factory()->create(['first_name' => 'Gorpzack', 'last_name' => 'Sootsnort']); + $userB = User::factory()->create(['first_name' => 'Skratcha', 'last_name' => 'Funguspike']); + + $assetA = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userA->id]); + $assetB = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => User::class, + 'value' => $userB->first_name . ' ' . $userB->last_name, + ], + 'operator' => 'contains', + 'logic' => 'AND' + ], + ]; + + $this->getFilteredAssets($filter) + ->assertOk() + ->assertJsonStructure([ + 'total', + 'rows', + ]) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 1)->etc()) + ->assertJsonFragment([ + 'id' => $assetB->id, + ]); + } + + public function testFilterAssetsAssetEqualsValue(): void + { + $parentAssetA = Asset::factory()->create(['asset_tag' => 'pc01', 'name' => 'Server']); + $parentAssetB = Asset::factory()->create(['asset_tag' => 'srv01', 'name' => 'Desktop']); + + $assetA = Asset::factory()->create(['assigned_type' => Asset::class, 'assigned_to' => $parentAssetA->id]); + $assetB = Asset::factory()->create(['assigned_type' => Asset::class, 'assigned_to' => $parentAssetB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => Asset::class, + 'value' => $parentAssetA->asset_tag + ], + 'operator' => 'equals', + 'logic' => 'AND' + ], + ]; + + $this->getFilteredAssets($filter) + ->assertOk() + ->assertJsonStructure([ + 'total', + 'rows', + ]) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 1)->etc()) + ->assertJsonFragment([ + 'id' => $assetA->id, + ]); + } + + public function testFilterAssetsLocationsContainsValue(): void + { + $locationA = Location::factory()->create(['name' => 'Berlin']); + $locationB = Location::factory()->create(['name' => 'Vienna']); + + $assetA = Asset::factory()->create(['assigned_type' => Location::class, 'assigned_to' => $locationA->id]); + $assetB = Asset::factory()->create(['assigned_type' => Location::class, 'assigned_to' => $locationB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => Location::class, + 'value' => 'Vie' + ], + 'operator' => 'contains', + 'logic' => 'AND' + ], + ]; + + $this->actingAsForApi(User::factory()->superuser()->create()) + ->getJson( + route('api.assets.index', [ + 'status' => '', + 'order_number' => '', + 'company_id' => '', + 'status_id' => '', + 'filter' => json_encode($filter), + 'search' => '', + 'sort' => 'id', + 'order' => 'asc', + 'offset' => '0', + 'limit' => '50', + ]) + ) + ->assertOk() + ->assertJsonStructure([ + 'total', + 'rows', + ]) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 1)->etc()) + ->assertJsonFragment([ + 'id' => $assetB->id, + ]); + } + + public function testFilterAssetsLocationsNotContainsValue(): void + { + $locationA = Location::factory()->create(['name' => 'Paris']); + $locationB = Location::factory()->create(['name' => 'London']); + + $assetA = Asset::factory()->create(['assigned_type' => Location::class, 'assigned_to' => $locationA->id]); + $assetB = Asset::factory()->create(['assigned_type' => Location::class, 'assigned_to' => $locationB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => Location::class, + 'value' => 'Pa' + ], + 'operator' => 'contains', + 'logic' => 'NOT' + ], + ]; + + $this->getFilteredAssets($filter) + ->assertOk() + ->assertJsonStructure([ + 'total', + 'rows', + ]) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 1)->etc()) + ->assertJsonFragment([ + 'id' => $assetB->id, + ]); + } +} diff --git a/tests/Feature/AssetQuery/Api/CategoryQueryTest.php b/tests/Feature/AssetQuery/Api/CategoryQueryTest.php new file mode 100644 index 000000000000..769b913d8557 --- /dev/null +++ b/tests/Feature/AssetQuery/Api/CategoryQueryTest.php @@ -0,0 +1,167 @@ +create(); + $categoryB = Category::factory()->create(); + + $modelA = AssetModel::factory()->create(['category_id' => $categoryA->id]); + $modelB = AssetModel::factory()->create(['category_id' => $categoryB->id]); + + // Assets + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + + $filter = [ + [ + 'field' => 'category', + 'value' => [""], + 'operator' => 'contains', + 'logic' => 'AND', + ], + ]; + + $this->actingAsForApi(User::factory()->superuser()->create()) + ->getJson( + route('api.assets.index', [ + 'status' => '', + 'order_number' => '', + 'company_id' => '', + 'status_id' => '', + 'filter' => json_encode($filter), + 'search' => '', + 'sort' => 'id', + 'order' => 'asc', + 'offset' => '0', + 'limit' => '50', + ]) + ) + ->assertOk() + ->assertJsonStructure([ + 'total', + 'rows', + ]) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 2)->etc()) + ->assertJsonFragment([ + 'id' => $assetA->id, + ]) + ->assertJsonFragment([ + 'id' => $assetB->id, + ]); + } + + public function testFilterAssetsCategoryString(): void + { + $categoryA = Category::factory()->create(); + $categoryB = Category::factory()->create(); + + $modelA = AssetModel::factory()->create(['category_id' => $categoryA->id]); + $modelB = AssetModel::factory()->create(['category_id' => $categoryB->id]); + + // Assets + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + + $filter = [ + [ + 'field' => 'category', + 'value' => [$categoryA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ], + ]; + + $this->actingAsForApi(User::factory()->superuser()->create()) + ->getJson( + route('api.assets.index', [ + 'status' => '', + 'order_number' => '', + 'company_id' => '', + 'status_id' => '', + 'filter' => json_encode($filter), + 'search' => '', + 'sort' => 'id', + 'order' => 'asc', + 'offset' => '0', + 'limit' => '50', + ]) + ) + ->assertOk() + ->assertJsonStructure([ + 'total', + 'rows', + ]) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 1)->etc()) + ->assertJsonFragment([ + 'id' => $assetA->id, + ]); + } + + public function testFilterAssetsCategoryArray(): void + { + $categoryA = Category::factory()->create(); + $categoryB = Category::factory()->create(); + $categoryC = Category::factory()->create(); + + $modelA = AssetModel::factory()->create(['category_id' => $categoryA->id]); + $modelB = AssetModel::factory()->create(['category_id' => $categoryB->id]); + $modelC = AssetModel::factory()->create(['category_id' => $categoryC->id]); + + // Assets + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + $assetC = Asset::factory()->create(['model_id' => $modelC->id]); + + $filter = [ + [ + 'field' => 'category', + 'value' => [$categoryA->id, $categoryC->id], + 'operator' => 'contains', + 'logic' => 'AND', + ], + ]; + + $this->actingAsForApi(User::factory()->superuser()->create()) + ->getJson( + route('api.assets.index', [ + 'status' => '', + 'order_number' => '', + 'company_id' => '', + 'status_id' => '', + 'filter' => json_encode($filter), + 'search' => '', + 'sort' => 'id', + 'order' => 'asc', + 'offset' => '0', + 'limit' => '50', + ]) + ) + ->assertOk() + ->assertJsonStructure([ + 'total', + 'rows', + ]) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 2)->etc()) + ->assertJsonFragment([ + 'id' => $assetA->id, + ]) + ->assertJsonFragment([ + 'id' => $assetC->id, + ]); + } +} diff --git a/tests/Feature/AssetQuery/Api/CombinedQueryTest.php b/tests/Feature/AssetQuery/Api/CombinedQueryTest.php new file mode 100644 index 000000000000..530dc112a45c --- /dev/null +++ b/tests/Feature/AssetQuery/Api/CombinedQueryTest.php @@ -0,0 +1,263 @@ +actingAsForApi(User::factory()->superuser()->create())->getJson( + route('api.assets.index', [ + 'status' => '', + 'order_number' => '', + 'company_id' => '', + 'status_id' => '', + 'filter' => json_encode($filter), + 'search' => '', + 'sort' => 'id', + 'order' => 'asc', + 'offset' => '0', + 'limit' => '50', + ]) + ); + } + + public function testFilterAssetModelLocationArrayManufacturerArray(): void + { + $modelA = AssetModel::factory()->create(); + $modelB = AssetModel::factory()->create(); + $locationA = Location::factory()->create(); + $locationB = Location::factory()->create(); + $locationC = Location::factory()->create(); + $manufacturerA = Manufacturer::factory()->create(); + $manufacturerB = Manufacturer::factory()->create(); + + $modelA->manufacturer_id = $manufacturerA->id; + $modelA->save(); + $modelB->manufacturer_id = $manufacturerB->id; + $modelB->save(); + + $assetA = Asset::factory()->create(['model_id' => $modelA->id, 'location_id' => $locationA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelA->id, 'location_id' => $locationB->id]); + $assetC = Asset::factory()->create(['model_id' => $modelB->id, 'location_id' => $locationA->id]); + $assetD = Asset::factory()->create(['model_id' => $modelB->id, 'location_id' => $locationB->id]); + $assetE = Asset::factory()->create(['location_id' => $locationC->id]); + + $filter = [ + [ + 'field' => 'model', + 'value' => [$modelA->id, $modelB->id], + 'operator' => 'contains', + 'logic' => 'AND', + ], + [ + 'field' => 'location', + 'value' => [$locationA->id, $locationB->id], + 'operator' => 'contains', + 'logic' => 'AND', + ], + [ + 'field' => 'manufacturer', + 'value' => [$manufacturerA->id, $manufacturerB->id], + 'operator' => 'contains', + 'logic' => 'AND', + ], + ]; + + $this->getFilteredAssets($filter) + ->assertOk() + ->assertJsonStructure([ + 'total', + 'rows', + ]) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 4)->etc()) + ->assertJsonFragment([ + 'id' => $assetA->id, + ]) + ->assertJsonFragment([ + 'id' => $assetB->id, + ]) + ->assertJsonFragment([ + 'id' => $assetC->id, + ]) + ->assertJsonFragment([ + 'id' => $assetD->id, + ]); + } + + public function testFilterWithDuplicateValuesReturnsUniqueResults(): void + { + $modelA = AssetModel::factory()->create(); + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + + $filter = [ + [ + 'field' => 'model', + 'value' => [$modelA->name, $modelA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ], + ]; + + $this->getFilteredAssets($filter) + ->assertOk() + ->assertJsonStructure([ + 'total', + 'rows', + ]) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 1)->etc()) + ->assertJsonFragment([ + 'id' => $assetA->id, + ]); + } + + public function testFilterAssetsConflictingFiltersReturnNone(): void + { + $modelA = AssetModel::factory()->create(); + $manufacturerB = Manufacturer::factory()->create(); + $modelA->manufacturer_id = $manufacturerB->id + 1; // Not matching + $modelA->save(); + + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + + $filter = [ + [ + 'field' => 'model', + 'value' => [$modelA->name, $modelA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ], + [ + 'field' => 'manufacturer', + 'value' => ['NonexistentManufacturer'], + 'operator' => 'contains', + 'logic' => 'AND', + ], + ]; + + $this->getFilteredAssets($filter) + ->assertOk() + ->assertJsonStructure([ + 'total', + 'rows', + ]) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 0)->etc()); + } + + public function testFilterAssetAllFiltersAsStrings(): void + { + $model = AssetModel::factory()->create(); + $location = Location::factory()->create(); + $manufacturer = Manufacturer::factory()->create(); + $status = Statuslabel::factory()->create(); + + $model->manufacturer_id = $manufacturer->id; + $model->save(); + + $assetA = Asset::factory()->create([ + 'model_id' => $model->id, + 'location_id' => $location->id, + 'status_id' => $status->id + ]); + $assetB = Asset::factory()->create(); // Should not match + + $filter = [ + [ + 'field' => 'model', + 'value' => [$model->name], + 'operator' => 'contains', + 'logic' => 'AND', + ],[ + 'field' => 'location', + 'value' => [$location->name], + 'operator' => 'contains', + 'logic' => 'AND', + ],[ + 'field' => 'manufacturer', + 'value' => [$manufacturer->name], + 'operator' => 'contains', + 'logic' => 'AND', + ],[ + 'field' => 'status_label', + 'value' => [$status->name], + 'operator' => 'contains', + 'logic' => 'AND', + ], + ]; + + $this->getFilteredAssets($filter) + ->assertOk() + ->assertJsonStructure([ + 'total', + 'rows', + ]) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 1)->etc()) + ->assertJsonFragment([ + 'id' => $assetA->id, + ]); + } + + public function testFilterAssetModelLocationManufacturer(): void + { + $modelA = AssetModel::factory()->create(); + $modelB = AssetModel::factory()->create(); + $locationA = Location::factory()->create(); + $locationB = Location::factory()->create(); + $manufacturerA = Manufacturer::factory()->create(); + $manufacturerB = Manufacturer::factory()->create(); + + $modelA->manufacturer_id = $manufacturerA->id; + $modelA->save(); + $modelB->manufacturer_id = $manufacturerB->id; + $modelB->save(); + + $assetA = Asset::factory()->create(['model_id' => $modelA->id, 'location_id' => $locationA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelA->id, 'location_id' => $locationB->id]); + $assetC = Asset::factory()->create(['model_id' => $modelB->id, 'location_id' => $locationA->id]); + $assetD = Asset::factory()->create(['model_id' => $modelB->id, 'location_id' => $locationB->id]); + + $filter = [ + [ + 'field' => 'model', + 'value' => [$modelA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ],[ + 'field' => 'location', + 'value' => [$locationA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ],[ + 'field' => 'manufacturer', + 'value' => [$manufacturerA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ], + ]; + + $this->getFilteredAssets($filter) + ->assertOk() + ->assertJsonStructure([ + 'total', + 'rows', + ]) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 1)->etc()) + ->assertJsonFragment([ + 'id' => $assetA->id, + ]); + } +} diff --git a/tests/Feature/AssetQuery/Api/CompanyQueryTest.php b/tests/Feature/AssetQuery/Api/CompanyQueryTest.php new file mode 100644 index 000000000000..ebcfe306e2a4 --- /dev/null +++ b/tests/Feature/AssetQuery/Api/CompanyQueryTest.php @@ -0,0 +1,171 @@ +create(); + $companyB = Company::factory()->create(); + + // Assets with direct company_id + $assetA = Asset::factory()->create([ + 'company_id' => $companyA->id, + ]); + $assetB = Asset::factory()->create([ + 'company_id' => $companyB->id, + ]); + + $filter = [ + [ + 'field' => 'company', + 'value' => [''], + 'operator' => 'contains', + 'logic' => 'AND', + ], + ]; + + $this->actingAsForApi(User::factory()->superuser()->create()) + ->getJson( + route('api.assets.index', [ + 'status' => '', + 'order_number' => '', + 'company_id' => '', + 'status_id' => '', + 'filter' => json_encode($filter), + 'search' => '', + 'sort' => 'id', + 'order' => 'asc', + 'offset' => '0', + 'limit' => '50', + ]) + ) + ->assertOk() + ->assertJsonStructure([ + 'total', + 'rows', + ]) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 2)->etc()) + ->assertJsonFragment([ + 'id' => $assetA->id, + ]) + ->assertJsonFragment([ + 'id' => $assetB->id, + ]); + } + + public function testFilterAssetsCompanyString(): void + { + $companyA = Company::factory()->create(); + $companyB = Company::factory()->create(); + + // Assets with direct company_id + $assetA = Asset::factory()->create([ + 'company_id' => $companyA->id, + ]); + $assetB = Asset::factory()->create([ + 'company_id' => $companyB->id, + ]); + + $filter = [ + [ + 'field' => 'company', + 'value' => [$companyB->name], + 'operator' => 'contains', + 'logic' => 'AND', + ], + ]; + + $this->actingAsForApi(User::factory()->superuser()->create()) + ->getJson( + route('api.assets.index', [ + 'status' => '', + 'order_number' => '', + 'company_id' => '', + 'status_id' => '', + 'filter' => json_encode($filter), + 'search' => '', + 'sort' => 'id', + 'order' => 'asc', + 'offset' => '0', + 'limit' => '50', + ]) + ) + ->assertOk() + ->assertJsonStructure([ + 'total', + 'rows', + ]) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 1)->etc()) + ->assertJsonFragment([ + 'id' => $assetB->id, + ]); + } + + public function testFilterAssetsCompanyArray(): void + { + $companyA = Company::factory()->create(); + $companyB = Company::factory()->create(); + $companyC = Company::factory()->create(); + + // Assets with direct company_id + $assetA = Asset::factory()->create([ + 'company_id' => $companyA->id, + ]); + $assetB = Asset::factory()->create([ + 'company_id' => $companyB->id, + ]); + $assetC = Asset::factory()->create([ + 'company_id' => $companyC->id, + ]); + + $filter = [ + [ + 'field' => 'company', + 'value' => [$companyB->id, $companyC->id], + 'operator' => 'contains', + 'logic' => 'AND', + ], + ]; + + $this->actingAsForApi(User::factory()->superuser()->create()) + ->getJson( + route('api.assets.index', [ + 'status' => '', + 'order_number' => '', + 'company_id' => '', + 'status_id' => '', + 'filter' => json_encode($filter), + 'search' => '', + 'sort' => 'id', + 'order' => 'asc', + 'offset' => '0', + 'limit' => '50', + ]) + ) + ->assertOk() + ->assertJsonStructure([ + 'total', + 'rows', + ]) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 2)->etc()) + ->assertJsonFragment([ + 'id' => $assetB->id, + ]) + ->assertJsonFragment([ + 'id' => $assetC->id, + ]); + } +} \ No newline at end of file diff --git a/tests/Feature/AssetQuery/Api/CustomFieldQueryTest.php b/tests/Feature/AssetQuery/Api/CustomFieldQueryTest.php new file mode 100644 index 000000000000..54f053ea4126 --- /dev/null +++ b/tests/Feature/AssetQuery/Api/CustomFieldQueryTest.php @@ -0,0 +1,238 @@ +string('custom_text')->nullable()->index(); + } + if (!Schema::hasColumn('assets', 'custom_flag')) { + $table->string('custom_flag')->nullable()->index(); + } + if (!Schema::hasColumn('assets', 'custom_code')) { + $table->string('custom_code')->nullable()->index(); + } + }); + } + + + public function testFilterBySingleCustomFieldStringLike(): void + { + $aMatch = Asset::factory()->create(['custom_text' => 'Here is another one']); + $aNoMatch1 = Asset::factory()->create(['custom_text' => 'Strings are awsome']); + $aNoMatch2 = Asset::factory()->create(['custom_text' => 'I am just a string']); + + $filter = [ + [ + 'field' => 'custom_text', + 'value' => 'is another', + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($aMatch)); + $this->assertFalse($results->contains($aNoMatch1)); + $this->assertFalse($results->contains($aNoMatch2)); + + + $this->actingAsForApi(User::factory()->superuser()->create()) + ->getJson( + route('api.assets.index', [ + 'status' => '', + 'order_number' => '', + 'company_id' => '', + 'status_id' => '', + 'filter' => json_encode($filter), + 'search' => '', + 'sort' => 'id', + 'order' => 'asc', + 'offset' => '0', + 'limit' => '50', + ]) + ) + ->assertOk() + ->assertJsonStructure([ + 'total', + 'rows', + ]) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 1)->etc()) + ->assertJsonFragment([ + 'id' => $aMatch->id, + ]); + } + + public function testFilterMultipleCustomFieldsCombined(): void + { + $hit = Asset::factory()->create(['custom_text' => 'Report Q3', 'custom_code' => 'R-2025']); + $missText = Asset::factory()->create(['custom_text' => 'Notes Q3', 'custom_code' => 'R-2025']); + $missCode = Asset::factory()->create(['custom_text' => 'Report Q3', 'custom_code' => 'X-0001']); + + $filter = [ + [ + 'field' => 'custom_text', + 'value' => 'Report', + 'operator' => 'contains', + 'logic' => 'AND', + ], + [ + 'field' => 'custom_code', + 'value' => 'R-2025', + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($hit)); + $this->assertFalse($results->contains($missText)); + $this->assertFalse($results->contains($missCode)); + + $this->actingAsForApi(User::factory()->superuser()->create()) + ->getJson( + route('api.assets.index', [ + 'status' => '', + 'order_number' => '', + 'company_id' => '', + 'status_id' => '', + 'filter' => json_encode($filter), + 'search' => '', + 'sort' => 'id', + 'order' => 'asc', + 'offset' => '0', + 'limit' => '50', + ]) + ) + ->assertOk() + ->assertJsonStructure([ + 'total', + 'rows', + ]) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 1)->etc()) + ->assertJsonFragment([ + 'id' => $hit->id, + ]); + } + + public function testFilterWithSpecialCharactersInCustomField(): void + { + + $match = Asset::factory()->create(['custom_text' => 'Mödël#1 (ß)']); + $nope = Asset::factory()->create(['custom_text' => 'ÄäÖöÜüëÅ']); + + $filter = [ + [ + 'field' => 'custom_text', + 'value' => 'Mödël#1', + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($match)); + $this->assertFalse($results->contains($nope)); + + $this->actingAsForApi(User::factory()->superuser()->create()) + ->getJson( + route('api.assets.index', [ + 'status' => '', + 'order_number' => '', + 'company_id' => '', + 'status_id' => '', + 'filter' => json_encode($filter), + 'search' => '', + 'sort' => 'id', + 'order' => 'asc', + 'offset' => '0', + 'limit' => '50', + ]) + ) + ->assertOk() + ->assertJsonStructure([ + 'total', + 'rows', + ]) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 1)->etc()) + ->assertJsonFragment([ + 'id' => $match->id, + ]); + } + + public function testFilterWithUTF8CharactersInCustomField(): void + { + + $this->markIncompleteIfMySQL(); + + $match = Asset::factory()->create(['custom_text' => '🥶🎃😅']); + $nope = Asset::factory()->create(['custom_text' => '🙃🥳🙄😵‍💫']); + + $filter = [ + [ + 'field' => 'custom_text', + 'value' => '🎃', + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($match)); + $this->assertFalse($results->contains($nope)); + + $this->actingAsForApi(User::factory()->superuser()->create()) + ->getJson( + route('api.assets.index', [ + 'status' => '', + 'order_number' => '', + 'company_id' => '', + 'status_id' => '', + 'filter' => json_encode($filter), + 'search' => '', + 'sort' => 'id', + 'order' => 'asc', + 'offset' => '0', + 'limit' => '50', + ]) + ) + ->assertOk() + ->assertJsonStructure([ + 'total', + 'rows', + ]) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 1)->etc()) + ->assertJsonFragment([ + 'id' => $match->id, + ]); + } + + +} \ No newline at end of file diff --git a/tests/Feature/AssetQuery/Api/DateQueryTest.php b/tests/Feature/AssetQuery/Api/DateQueryTest.php new file mode 100644 index 000000000000..001d223aec50 --- /dev/null +++ b/tests/Feature/AssetQuery/Api/DateQueryTest.php @@ -0,0 +1,154 @@ +actingAsForApi(User::factory()->superuser()->create())->getJson( + route('api.assets.index', [ + 'status' => '', + 'order_number' => '', + 'company_id' => '', + 'status_id' => '', + 'filter' => json_encode($filter), + 'search' => '', + 'sort' => 'id', + 'order' => 'asc', + 'offset' => '0', + 'limit' => '50', + ]) + ); + } + + public function testPurchaseDateQueryStart() + { + Carbon::setTestNow(Carbon::create(2023, 4, 16)); + + Asset::factory()->create(['purchase_date' => Carbon::now()->addDays(14)->toDateString()]); // asset A + $assetB = Asset::factory()->create(['purchase_date' => Carbon::now()->addWeeks(14)->toDateString()]); // asset B + $assetC = Asset::factory()->create(['purchase_date' => Carbon::now()->addMonths(14)->toDateString()]); // asset C + + $filter = [[ + 'field' => 'purchase_date', + 'value' => ['startDate' => Carbon::now()->addMonths(3)->toDateString()], + 'operator' => 'contains', + 'logic' => 'AND', + ]]; + + $this->actingAsForApi(User::factory()->superuser()->create()) + ->getJson(route('api.assets.index', ['filter' => json_encode($filter)])) + ->assertOk() + ->assertJsonStructure(['total', 'rows']) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 2)->etc()) + ->assertJsonFragment(['id' => $assetB->id]) + ->assertJsonFragment(['id' => $assetC->id]); + } + + public function testPurchaseDateQueryRange() + { + Carbon::setTestNow(Carbon::create(2011, 5, 15)); + + $assetA = Asset::factory()->create(['purchase_date' => Carbon::now()->addWeeks(50)->toDateString()]); + $assetB = Asset::factory()->create(['purchase_date' => Carbon::now()->addWeeks(75)->toDateString()]); + $assetC = Asset::factory()->create(['purchase_date' => Carbon::now()->addWeeks(100)->toDateString()]); + $assetD = Asset::factory()->create(['purchase_date' => Carbon::now()->addWeeks(125)->toDateString()]); + $assetE = Asset::factory()->create(['purchase_date' => Carbon::now()->addWeeks(150)->toDateString()]); + + $filter = [[ + 'field' => 'purchase_date', + 'value' => [ + 'startDate' => Carbon::now()->addWeeks(70)->toDateString(), + 'endDate' => Carbon::now()->addWeeks(130)->toDateString(), + ], + 'operator' => 'contains', + 'logic' => 'AND', + ]]; + + $this->actingAsForApi(User::factory()->superuser()->create()) + ->getJson(route('api.assets.index', ['filter' => json_encode($filter)])) + ->assertOk() + ->assertJsonStructure(['total', 'rows']) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 3)->etc()) + ->assertJsonFragment(['id' => $assetB->id]) + ->assertJsonFragment(['id' => $assetC->id]) + ->assertJsonFragment(['id' => $assetD->id]); + } + + public function testEolDateQueryEnd() + { + Carbon::setTestNow(Carbon::create(2020,1,1)); + + $owner = User::factory()->superuser()->create(); + + $modelA = AssetModel::factory()->create(['eol' => 12]); + $modelB = AssetModel::factory()->create(['eol' => 24]); + $modelC = AssetModel::factory()->create(['eol' => 36]); + + $purchase = '2020-01-01'; + $eolA = '2021-01-01'; + $eolB = '2022-01-01'; + $eolC = '2023-01-01'; + + $assetA = Asset::factory()->create([ + 'model_id' => $modelA->id, + 'purchase_date' => $purchase, + 'asset_tag' => 'API-EOLEND-A', + 'created_by' => $owner->id, + ]); + $assetB = Asset::factory()->create([ + 'model_id' => $modelB->id, + 'purchase_date' => $purchase, + 'asset_tag' => 'API-EOLEND-B', + 'created_by' => $owner->id, + ]); + $assetC = Asset::factory()->create([ + 'model_id' => $modelC->id, + 'purchase_date' => $purchase, + 'asset_tag' => 'API-EOLEND-C', + 'created_by' => $owner->id, + ]); + + // needed because on creation there is a randomizer in the factory + $assetA->update(['asset_eol_date' => $eolA]); + $assetB->update(['asset_eol_date' => $eolB]); + $assetC->update(['asset_eol_date' => $eolC]); + + $filter = [ + [ + 'field' => 'asset_eol_date', + 'value' => [ + 'startDate' => '2020-12-01', + 'endDate' => '2021-01-31', + ], + 'operator' => 'contains', + 'logic' => 'AND', + ], + [ + 'field' => 'created_by', + 'value' => [$owner->id], + 'operator' => 'equals', + 'logic' => 'AND', + ], + ]; + + $response = $this->getFilteredAssets($filter); + + $response->assertOk()->assertJsonStructure(['total','rows']); + + $ids = collect($response->json('rows'))->pluck('id')->all(); + $this->assertSame([$assetA->id], $ids, 'Es darf nur assetA enthalten sein.'); + } + +} diff --git a/tests/Feature/AssetQuery/Api/LocationQueryTest.php b/tests/Feature/AssetQuery/Api/LocationQueryTest.php new file mode 100644 index 000000000000..32b7efe8e37e --- /dev/null +++ b/tests/Feature/AssetQuery/Api/LocationQueryTest.php @@ -0,0 +1,156 @@ +create(); + $locationB = Location::factory()->create(); + + // Assets + $assetA = Asset::factory()->create(['location_id' => $locationA->id]); + $assetB = Asset::factory()->create(['location_id' => $locationB->id]); + + $filter = [ + [ + 'field' => 'location', + 'value' => [''], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $this->actingAsForApi(User::factory()->superuser()->create()) + ->getJson( + route('api.assets.index', [ + 'status' => '', + 'order_number' => '', + 'company_id' => '', + 'status_id' => '', + 'filter' => json_encode($filter), + 'search' => '', + 'sort' => 'id', + 'order' => 'asc', + 'offset' => '0', + 'limit' => '50', + ]) + ) + ->assertOk() + ->assertJsonStructure([ + 'total', + 'rows', + ]) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 2)->etc()) + ->assertJsonFragment([ + 'id' => $assetA->id, + ]) + ->assertJsonFragment([ + 'id' => $assetB->id, + ]); + } + + public function testFilterAssetsLocationString(): void + { + $locationA = Location::factory()->create(); + $locationB = Location::factory()->create(); + + // Assets + $assetA = Asset::factory()->create(['location_id' => $locationA->id]); + $assetB = Asset::factory()->create(['location_id' => $locationB->id]); + + $filter = [ + [ + 'field' => 'location', + 'value' => [$locationA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $this->actingAsForApi(User::factory()->superuser()->create()) + ->getJson( + route('api.assets.index', [ + 'status' => '', + 'order_number' => '', + 'company_id' => '', + 'status_id' => '', + 'filter' => json_encode($filter), + 'search' => '', + 'sort' => 'id', + 'order' => 'asc', + 'offset' => '0', + 'limit' => '50', + ]) + ) + ->assertOk() + ->assertJsonStructure([ + 'total', + 'rows', + ]) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 1)->etc()) + ->assertJsonFragment([ + 'id' => $assetA->id, + ]); + } + + public function testFilterAssetsLocationArray(): void + { + $locationA = Location::factory()->create(); + $locationB = Location::factory()->create(); + $locationC = Location::factory()->create(); + + $assetA = Asset::factory()->create(['location_id' => $locationA->id]); + $assetB = Asset::factory()->create(['location_id' => $locationB->id]); + $assetC = Asset::factory()->create(['location_id' => $locationC->id]); + + $filter = [ + [ + 'field' => 'location', + 'value' => [$locationA->id, $locationC->id], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $this->actingAsForApi(User::factory()->superuser()->create()) + ->getJson( + route('api.assets.index', [ + 'status' => '', + 'order_number' => '', + 'company_id' => '', + 'status_id' => '', + 'filter' => json_encode($filter), + 'search' => '', + 'sort' => 'id', + 'order' => 'asc', + 'offset' => '0', + 'limit' => '50', + ]) + ) + ->assertOk() + ->assertJsonStructure([ + 'total', + 'rows', + ]) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 2)->etc()) + ->assertJsonFragment([ + 'id' => $assetA->id, + ]) + ->assertJsonFragment([ + 'id' => $assetC->id, + ]); + } +} diff --git a/tests/Feature/AssetQuery/Api/ManufacturerQueryTest.php b/tests/Feature/AssetQuery/Api/ManufacturerQueryTest.php new file mode 100644 index 000000000000..3a3b16912f53 --- /dev/null +++ b/tests/Feature/AssetQuery/Api/ManufacturerQueryTest.php @@ -0,0 +1,167 @@ +create(); + $manufacturerB = Manufacturer::factory()->create(); + + $modelA = AssetModel::factory()->create(['manufacturer_id' => $manufacturerA->id]); + $modelB = AssetModel::factory()->create(['manufacturer_id' => $manufacturerB->id]); + + // Assets + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + + $filter = [ + [ + 'field' => 'manufacturer', + 'value' => [''], + 'operator' => 'contains', + 'logic' => 'AND', + ], + ]; + + $this->actingAsForApi(User::factory()->superuser()->create()) + ->getJson( + route('api.assets.index', [ + 'status' => '', + 'order_number' => '', + 'company_id' => '', + 'status_id' => '', + 'filter' => json_encode($filter), + 'search' => '', + 'sort' => 'id', + 'order' => 'asc', + 'offset' => '0', + 'limit' => '50', + ]) + ) + ->assertOk() + ->assertJsonStructure([ + 'total', + 'rows', + ]) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 2)->etc()) + ->assertJsonFragment([ + 'id' => $assetA->id, + ]) + ->assertJsonFragment([ + 'id' => $assetB->id, + ]); + } + + public function testFilterAssetsManufacturerString(): void + { + $manufacturerA = Manufacturer::factory()->create(); + $manufacturerB = Manufacturer::factory()->create(); + + $modelA = AssetModel::factory()->create(['manufacturer_id' => $manufacturerA->id]); + $modelB = AssetModel::factory()->create(['manufacturer_id' => $manufacturerB->id]); + + // Assets + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + + $filter = [ + [ + 'field' => 'manufacturer', + 'value' => [$manufacturerA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ], + ]; + + $this->actingAsForApi(User::factory()->superuser()->create()) + ->getJson( + route('api.assets.index', [ + 'status' => '', + 'order_number' => '', + 'company_id' => '', + 'status_id' => '', + 'filter' => json_encode($filter), + 'search' => '', + 'sort' => 'id', + 'order' => 'asc', + 'offset' => '0', + 'limit' => '50', + ]) + ) + ->assertOk() + ->assertJsonStructure([ + 'total', + 'rows', + ]) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 1)->etc()) + ->assertJsonFragment([ + 'id' => $assetA->id, + ]); + } + + public function testFilterAssetsManufacturerArray(): void + { + $manufacturerA = Manufacturer::factory()->create(); + $manufacturerB = Manufacturer::factory()->create(); + $manufacturerC = Manufacturer::factory()->create(); + + $modelA = AssetModel::factory()->create(['manufacturer_id' => $manufacturerA->id]); + $modelB = AssetModel::factory()->create(['manufacturer_id' => $manufacturerB->id]); + $modelC = AssetModel::factory()->create(['manufacturer_id' => $manufacturerC->id]); + + // Assets + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + $assetC = Asset::factory()->create(['model_id' => $modelC->id]); + + $filter = [ + [ + 'field' => 'manufacturer', + 'value' => [$manufacturerA->id, $manufacturerC->id], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $this->actingAsForApi(User::factory()->superuser()->create()) + ->getJson( + route('api.assets.index', [ + 'status' => '', + 'order_number' => '', + 'company_id' => '', + 'status_id' => '', + 'filter' => json_encode($filter), + 'search' => '', + 'sort' => 'id', + 'order' => 'asc', + 'offset' => '0', + 'limit' => '50', + ]) + ) + ->assertOk() + ->assertJsonStructure([ + 'total', + 'rows', + ]) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 2)->etc()) + ->assertJsonFragment([ + 'id' => $assetA->id, + ]) + ->assertJsonFragment([ + 'id' => $assetC->id, + ]); + } +} diff --git a/tests/Feature/AssetQuery/Api/ModelNumberQueryTest.php b/tests/Feature/AssetQuery/Api/ModelNumberQueryTest.php new file mode 100644 index 000000000000..0dd9a4109d19 --- /dev/null +++ b/tests/Feature/AssetQuery/Api/ModelNumberQueryTest.php @@ -0,0 +1,155 @@ +create(); + $modelB = AssetModel::factory()->create(); + + // Assets + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + + $filter = [ + [ + 'field' => 'model_number', + 'value' => [''], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $this->actingAsForApi(User::factory()->superuser()->create()) + ->getJson( + route('api.assets.index', [ + 'status' => '', + 'order_number' => '', + 'company_id' => '', + 'status_id' => '', + 'filter' => json_encode($filter), + 'search' => '', + 'sort' => 'id', + 'order' => 'asc', + 'offset' => '0', + 'limit' => '50', + ]) + ) + ->assertOk() + ->assertJsonStructure([ + 'total', + 'rows', + ]) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 2)->etc()) + ->assertJsonFragment([ + 'id' => $assetA->id, + ]) + ->assertJsonFragment([ + 'id' => $assetB->id, + ]); + } + + public function testFilterAssetsModelNumberString(): void + { + $modelA = AssetModel::factory()->create(); + $modelB = AssetModel::factory()->create(); + + // Assets + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + + $filter = [ + [ + 'field' => 'model_number', + 'value' => [$modelA->model_number], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $this->actingAsForApi(User::factory()->superuser()->create()) + ->getJson( + route('api.assets.index', [ + 'status' => '', + 'order_number' => '', + 'company_id' => '', + 'status_id' => '', + 'filter' => json_encode($filter), + 'search' => '', + 'sort' => 'id', + 'order' => 'asc', + 'offset' => '0', + 'limit' => '50', + ]) + ) + ->assertOk() + ->assertJsonStructure([ + 'total', + 'rows', + ]) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 1)->etc()) + ->assertJsonFragment([ + 'id' => $assetA->id, + ]); + } + + public function testFilterAssetsModelNumberArray(): void + { + $modelA = AssetModel::factory()->create(); + $modelB = AssetModel::factory()->create(); + $modelC = AssetModel::factory()->create(); + + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + $assetC = Asset::factory()->create(['model_id' => $modelC->id]); + + $filter = [ + [ + 'field' => 'model_number', + 'value' => [$modelA->model_number, $modelC->model_number], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $this->actingAsForApi(User::factory()->superuser()->create()) + ->getJson( + route('api.assets.index', [ + 'status' => '', + 'order_number' => '', + 'company_id' => '', + 'status_id' => '', + 'filter' => json_encode($filter), + 'search' => '', + 'sort' => 'id', + 'order' => 'asc', + 'offset' => '0', + 'limit' => '50', + ]) + ) + ->assertOk() + ->assertJsonStructure([ + 'total', + 'rows', + ]) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 2)->etc()) + ->assertJsonFragment([ + 'id' => $assetA->id, + ]) + ->assertJsonFragment([ + 'id' => $assetC->id, + ]); + } +} diff --git a/tests/Feature/AssetQuery/Api/ModelQueryTest.php b/tests/Feature/AssetQuery/Api/ModelQueryTest.php new file mode 100644 index 000000000000..29e49f189cd6 --- /dev/null +++ b/tests/Feature/AssetQuery/Api/ModelQueryTest.php @@ -0,0 +1,155 @@ +create(); + $modelB = AssetModel::factory()->create(); + + // Assets + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + + $filter = [ + [ + 'field' => 'model', + 'value' => [''], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $this->actingAsForApi(User::factory()->superuser()->create()) + ->getJson( + route('api.assets.index', [ + 'status' => '', + 'order_number' => '', + 'company_id' => '', + 'status_id' => '', + 'filter' => json_encode($filter), + 'search' => '', + 'sort' => 'id', + 'order' => 'asc', + 'offset' => '0', + 'limit' => '50', + ]) + ) + ->assertOk() + ->assertJsonStructure([ + 'total', + 'rows', + ]) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 2)->etc()) + ->assertJsonFragment([ + 'id' => $assetA->id, + ]) + ->assertJsonFragment([ + 'id' => $assetB->id, + ]); + } + + public function testFilterAssetsModelString(): void + { + $modelA = AssetModel::factory()->create(); + $modelB = AssetModel::factory()->create(); + + // Assets + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + + $filter = [ + [ + 'field' => 'model', + 'value' => [$modelA->id], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $this->actingAsForApi(User::factory()->superuser()->create()) + ->getJson( + route('api.assets.index', [ + 'status' => '', + 'order_number' => '', + 'company_id' => '', + 'status_id' => '', + 'filter' => json_encode($filter), + 'search' => '', + 'sort' => 'id', + 'order' => 'asc', + 'offset' => '0', + 'limit' => '50', + ]) + ) + ->assertOk() + ->assertJsonStructure([ + 'total', + 'rows', + ]) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 1)->etc()) + ->assertJsonFragment([ + 'id' => $assetA->id, + ]); + } + + public function testFilterAssetsModelArray(): void + { + $modelA = AssetModel::factory()->create(); + $modelB = AssetModel::factory()->create(); + $modelC = AssetModel::factory()->create(); + + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + $assetC = Asset::factory()->create(['model_id' => $modelC->id]); + + $filter = [ + [ + 'field' => 'model', + 'value' => [$modelA->id, $modelC->id], + 'operator' => 'contains', + 'logic' => 'AND', + ], + ]; + + $this->actingAsForApi(User::factory()->superuser()->create()) + ->getJson( + route('api.assets.index', [ + 'status' => '', + 'order_number' => '', + 'company_id' => '', + 'status_id' => '', + 'filter' => json_encode($filter), + 'search' => '', + 'sort' => 'id', + 'order' => 'asc', + 'offset' => '0', + 'limit' => '50', + ]) + ) + ->assertOk() + ->assertJsonStructure([ + 'total', + 'rows', + ]) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 2)->etc()) + ->assertJsonFragment([ + 'id' => $assetA->id, + ]) + ->assertJsonFragment([ + 'id' => $assetC->id, + ]); + } +} diff --git a/tests/Feature/AssetQuery/Api/QueryLogicTest.php b/tests/Feature/AssetQuery/Api/QueryLogicTest.php new file mode 100644 index 000000000000..4918e9361401 --- /dev/null +++ b/tests/Feature/AssetQuery/Api/QueryLogicTest.php @@ -0,0 +1,168 @@ +actingAsForApi($user) + ->getJson(route('api.assets.index', ['filter' => json_encode($filter)])) + ->assertOk() + ->assertJson(fn(AssertableJson $json) => + $json->has('total') + ->has('rows', count($expectedIds))->etc() + ) + ->assertJsonPath('rows.*.id', $expectedIds); + } + + public function testFilterAssetsWithModelAndManufacturerCombinations(): void + { + $apple = Manufacturer::factory()->create(['name' => 'Apple']); + $dell = Manufacturer::factory()->create(['name' => 'Dell']); + + $macbook = AssetModel::factory()->create(['name' => 'MacBook Pro', 'manufacturer_id' => $apple->id]); + $xps = AssetModel::factory()->create(['name' => 'XPS 15', 'manufacturer_id' => $dell->id]); + + $assetMacbook = Asset::factory()->create(['model_id' => $macbook->id]); + $assetDell = Asset::factory()->create(['model_id' => $xps->id]); + + $user = User::factory()->superuser()->create(); + + // -- Case 1: "macbook" AND "Apple" => Returns MacBook + $filter1 = [ + [ + 'field' => 'model', + 'value' => ['macbook'], + 'operator' => 'contains', + 'logic' => 'AND', + ], + [ + 'field' => 'manufacturer', + 'value' => ['Apple'], + 'operator' => 'contains', + 'logic' => 'AND', + ], + ]; + + $this->assertFilterResult($filter1, $user, (array)$assetMacbook->id); + + // -- Case 2: "macbook" AND NOT "Apple" => Returns nothing + $filter2 = [ + [ + 'field' => 'model', + 'value' => ['macbook'], + 'operator' => 'contains', + 'logic' => 'AND', + ], + [ + 'field' => 'manufacturer', + 'value' => ['Apple'], + 'operator' => 'contains', + 'logic' => 'NOT', + ], + ]; + + $this->assertFilterResult($filter2, $user, []); + + // -- Case 3: "macb" AND "Apple" => Returns MacBook (partial match) + $filter3 = [ + [ + 'field' => 'model', + 'value' => ['macb'], + 'operator' => 'contains', + 'logic' => 'AND', + ], + [ + 'field' => 'manufacturer', + 'value' => ['Apple'], + 'operator' => 'contains', + 'logic' => 'AND', + ], + ]; + + $this->assertFilterResult($filter3, $user, (array) $assetMacbook->id) + ->assertJsonMissingExact(['rows' => [['id' => $assetDell->id]]]); + + // -- Case 4: "macb" AND NOT "Apple" => Returns nothing + $filter4 = [ + [ + 'field' => 'model', + 'value' => ['macb'], + 'operator' => 'contains', + 'logic' => 'AND', + ], + [ + 'field' => 'manufacturer', + 'value' => ['Apple'], + 'operator' => 'contains', + 'logic' => 'NOT', + ], + ]; + + $this->assertFilterResult($filter4, $user, []); + } + + public function testFilterModelContainsBookButNotAppleManufacturer(): void + { + // Create manufacturers + $apple = Manufacturer::factory()->create(['name' => 'Apple']); + $microsoft = Manufacturer::factory()->create(['name' => 'Microsoft']); + $asus = Manufacturer::factory()->create(['name' => 'Asus']); + + // Create models + $macbook = AssetModel::factory()->create(['name' => 'MacBook', 'manufacturer_id' => $apple->id]); + $surfacebook = AssetModel::factory()->create(['name' => 'SurfaceBook', 'manufacturer_id' => $microsoft->id]); + $zenbook = AssetModel::factory()->create(['name' => 'ZenBook', 'manufacturer_id' => $asus->id]); + + // Create assets + $assetMacbook = Asset::factory()->create(['model_id' => $macbook->id]); + $assetSurfacebook = Asset::factory()->create(['model_id' => $surfacebook->id]); + $assetZenbook = Asset::factory()->create(['model_id' => $zenbook->id]); + + // Create user + $user = User::factory()->superuser()->create(); + + // Build filter: model contains "book", manufacturer NOT Apple + $filter = [ + [ + 'field' => 'model', + 'value' => ['book'], + 'operator' => 'contains', + 'logic' => 'AND', + ], + [ + 'field' => 'manufacturer', + 'value' => [$apple->id], + 'operator' => 'equals', + 'logic' => 'NOT', + ], + ]; + + // Make request and assert + $this->actingAsForApi($user) + ->getJson(route('api.assets.index', ['filter' => json_encode($filter)])) + ->assertOk() + ->assertJson(fn(AssertableJson $json) => + $json + ->has('total') + ->has('rows', 2) + ->etc() + ) + ->assertJsonFragment(['id' => $assetSurfacebook->id]) + ->assertJsonFragment(['id' => $assetZenbook->id]) + ->assertJsonMissing(['id' => $assetMacbook->id]); + } +} + diff --git a/tests/Feature/AssetQuery/Api/RtdLocationQueryTest.php b/tests/Feature/AssetQuery/Api/RtdLocationQueryTest.php new file mode 100644 index 000000000000..74612e9adb28 --- /dev/null +++ b/tests/Feature/AssetQuery/Api/RtdLocationQueryTest.php @@ -0,0 +1,156 @@ +create(); + $locationB = Location::factory()->create(); + + // Assets + $assetA = Asset::factory()->create(['rtd_location_id' => $locationA->id]); + $assetB = Asset::factory()->create(['rtd_location_id' => $locationB->id]); + + $filter = [ + [ + 'field' => 'rtd_location', + 'value' => [''], + 'operator' => 'contains', + 'logic' => 'AND', + ], + ]; + + $this->actingAsForApi(User::factory()->superuser()->create()) + ->getJson( + route('api.assets.index', [ + 'status' => '', + 'order_number' => '', + 'company_id' => '', + 'status_id' => '', + 'filter' => json_encode($filter), + 'search' => '', + 'sort' => 'id', + 'order' => 'asc', + 'offset' => '0', + 'limit' => '50', + ]) + ) + ->assertOk() + ->assertJsonStructure([ + 'total', + 'rows', + ]) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 2)->etc()) + ->assertJsonFragment([ + 'id' => $assetA->id, + ]) + ->assertJsonFragment([ + 'id' => $assetB->id, + ]); + } + + public function testFilterAssetsRtdLocationString(): void + { + $locationA = Location::factory()->create(); + $locationB = Location::factory()->create(); + + // Assets + $assetA = Asset::factory()->create(['rtd_location_id' => $locationA->id]); + $assetB = Asset::factory()->create(['rtd_location_id' => $locationB->id]); + + $filter = [ + [ + "field"=>"rtd_location", + "value"=>[$locationA->name], + "operator"=>"contains", + "logic"=>"AND" + ] + ]; + + $this->actingAsForApi(User::factory()->superuser()->create()) + ->getJson( + route('api.assets.index', [ + 'status' => '', + 'order_number' => '', + 'company_id' => '', + 'status_id' => '', + 'filter' => json_encode($filter), + 'search' => '', + 'sort' => 'id', + 'order' => 'asc', + 'offset' => '0', + 'limit' => '50', + ]) + ) + ->assertOk() + ->assertJsonStructure([ + 'total', + 'rows', + ]) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 1)->etc()) + ->assertJsonFragment([ + 'id' => $assetA->id, + ]); + } + + public function testFilterAssetsRtdLocationArray(): void + { + $locationA = Location::factory()->create(); + $locationB = Location::factory()->create(); + $locationC = Location::factory()->create(); + + $assetA = Asset::factory()->create(['rtd_location_id' => $locationA->id]); + $assetB = Asset::factory()->create(['rtd_location_id' => $locationB->id]); + $assetC = Asset::factory()->create(['rtd_location_id' => $locationC->id]); + + $filter = [ + [ + 'field' => 'rtd_location', + 'value' => [$locationA->id, $locationC->id], + 'operator' => 'contains', + 'logic' => 'AND', + ], + ]; + + $this->actingAsForApi(User::factory()->superuser()->create()) + ->getJson( + route('api.assets.index', [ + 'status' => '', + 'order_number' => '', + 'company_id' => '', + 'status_id' => '', + 'filter' => json_encode($filter), + 'search' => '', + 'sort' => 'id', + 'order' => 'asc', + 'offset' => '0', + 'limit' => '50', + ]) + ) + ->assertOk() + ->assertJsonStructure([ + 'total', + 'rows', + ]) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 2)->etc()) + ->assertJsonFragment([ + 'id' => $assetA->id, + ]) + ->assertJsonFragment([ + 'id' => $assetC->id, + ]); + } +} diff --git a/tests/Feature/AssetQuery/Api/SqlInjectionQueryTest.php b/tests/Feature/AssetQuery/Api/SqlInjectionQueryTest.php new file mode 100644 index 000000000000..f867c318d70a --- /dev/null +++ b/tests/Feature/AssetQuery/Api/SqlInjectionQueryTest.php @@ -0,0 +1,125 @@ +create(); + $locationA = Location::factory()->create(); + $userA = User::factory()->create(); + + $assetA = Asset::factory()->create(['assigned_type' => Asset::class, 'assigned_to' => $assignedAssetA->id]); + $assetB = Asset::factory()->create(['assigned_type' => Location::class, 'assigned_to' => $locationA->id]); + $assetC = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userA->id]); + + // Attempted SQL injection payload in the filter + $sqlInjectionString = "' OR '1'='1"; + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => User::class, + 'value' => $sqlInjectionString, + ], + 'operator' => 'contains', + 'logic' => "AND" + ] + ]; + + $response = $this->actingAsForApi(User::factory()->superuser()->create()) + ->getJson( + route('api.assets.index', [ + 'status' => '', + 'order_number' => '', + 'company_id' => '', + 'status_id' => '', + 'filter' => json_encode($filter), + 'search' => '', + 'sort' => 'id', + 'order' => 'asc', + 'offset' => '0', + 'limit' => '50', + ]) + ); + + + $response->assertOk(); // 200 + $response->assertJsonStructure(['total', 'rows']); + $response->assertJson(fn(AssertableJson $json) => + $json->where('total', 0) + ->where('rows', []) + ->etc() + ); + } + public function testFilterAssetsCategorySqlInjectionAttempt(): void + { + // Setup: Two legitimate categories, models, and assets + $categoryA = Category::factory()->create(); + $categoryB = Category::factory()->create(); + + $modelA = AssetModel::factory()->create(['category_id' => $categoryA->id]); + $modelB = AssetModel::factory()->create(['category_id' => $categoryB->id]); + + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + + // Attempted SQL injection payload in the filter + $sqlInjectionString = "' OR '1'='1"; + + $filter = [ + [ + "field"=>"category", + "value"=>[$sqlInjectionString], + "operator"=>"contains", + "logic"=>"AND" + ] + ]; + + $this->actingAsForApi(User::factory()->superuser()->create()) + ->getJson( + route('api.assets.index', [ + 'status' => '', + 'order_number' => '', + 'company_id' => '', + 'status_id' => '', + 'filter' => json_encode($filter), + 'search' => '', + 'sort' => 'id', + 'order' => 'asc', + 'offset' => '0', + 'limit' => '50', + ]) + ) + ->assertOk() + ->assertJsonStructure([ + 'total', + 'rows', + ]) + ->assertJson( + fn(AssertableJson $json) => + $json->where('total', 0) + ->where('rows', []) + ->etc() + ); + } + +} diff --git a/tests/Feature/AssetQuery/Api/StatusLabelQueryTest.php b/tests/Feature/AssetQuery/Api/StatusLabelQueryTest.php new file mode 100644 index 000000000000..68b5981496d9 --- /dev/null +++ b/tests/Feature/AssetQuery/Api/StatusLabelQueryTest.php @@ -0,0 +1,155 @@ +create(); + $statusArchived = Statuslabel::factory()->create(); + + // Assets + $assetA = Asset::factory()->create(['status_id' => $statusPending->id]); + $assetB = Asset::factory()->create(['status_id' => $statusArchived->id]); + + $filter = [ + [ + 'field' => 'status_label', + 'value' => [''], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $this->actingAsForApi(User::factory()->superuser()->create()) + ->getJson( + route('api.assets.index', [ + 'status' => '', + 'order_number' => '', + 'company_id' => '', + 'status_id' => '', + 'filter' => json_encode($filter), + 'search' => '', + 'sort' => 'id', + 'order' => 'asc', + 'offset' => '0', + 'limit' => '50', + ]) + ) + ->assertOk() + ->assertJsonStructure([ + 'total', + 'rows', + ]) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 2)->etc()) + ->assertJsonFragment([ + 'id' => $assetA->id, + ]) + ->assertJsonFragment([ + 'id' => $assetB->id, + ]); + } + + public function testFilterAssetsStatusString(): void + { + $statusPending = Statuslabel::factory()->create(); + $statusArchived = Statuslabel::factory()->create(); + + // Assets + $assetA = Asset::factory()->create(['status_id' => $statusPending->id]); + $assetB = Asset::factory()->create(['status_id' => $statusArchived->id]); + + $filter = [ + [ + 'field' => 'status_label', + 'value' => [$statusPending->id], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $this->actingAsForApi(User::factory()->superuser()->create()) + ->getJson( + route('api.assets.index', [ + 'status' => '', + 'order_number' => '', + 'company_id' => '', + 'status_id' => '', + 'filter' => json_encode($filter), + 'search' => '', + 'sort' => 'id', + 'order' => 'asc', + 'offset' => '0', + 'limit' => '50', + ]) + ) + ->assertOk() + ->assertJsonStructure([ + 'total', + 'rows', + ]) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 1)->etc()) + ->assertJsonFragment([ + 'id' => $assetA->id, + ]); + } + + public function testFilterAssetsStatusArray(): void + { + $statusPending = Statuslabel::factory()->create(); + $statusArchived = Statuslabel::factory()->create(); + $statusBroken = Statuslabel::factory()->create(); + + $assetA = Asset::factory()->create(['status_id' => $statusPending->id]); + $assetB = Asset::factory()->create(['status_id' => $statusArchived->id]); + $assetC = Asset::factory()->create(['status_id' => $statusBroken->id]); + + $filter = [ + [ + 'field' => 'status_label', + 'value' => [$statusPending->id, $statusBroken->id], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $this->actingAsForApi(User::factory()->superuser()->create()) + ->getJson( + route('api.assets.index', [ + 'status' => '', + 'order_number' => '', + 'company_id' => '', + 'status_id' => '', + 'filter' => json_encode($filter), + 'search' => '', + 'sort' => 'id', + 'order' => 'asc', + 'offset' => '0', + 'limit' => '50', + ]) + ) + ->assertOk() + ->assertJsonStructure([ + 'total', + 'rows', + ]) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 2)->etc()) + ->assertJsonFragment([ + 'id' => $assetA->id, + ]) + ->assertJsonFragment([ + 'id' => $assetC->id, + ]); + } +} diff --git a/tests/Feature/AssetQuery/Api/SupplierQueryTest.php b/tests/Feature/AssetQuery/Api/SupplierQueryTest.php new file mode 100644 index 000000000000..592a83d95c85 --- /dev/null +++ b/tests/Feature/AssetQuery/Api/SupplierQueryTest.php @@ -0,0 +1,155 @@ +create(); + $statusArchived = Supplier::factory()->create(); + + // Assets + $assetA = Asset::factory()->create(['supplier_id' => $supplierA->id]); + $assetB = Asset::factory()->create(['supplier_id' => $statusArchived->id]); + + $filter = [ + [ + 'field' => 'supplier', + 'value' => [''], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $this->actingAsForApi(User::factory()->superuser()->create()) + ->getJson( + route('api.assets.index', [ + 'status' => '', + 'order_number' => '', + 'company_id' => '', + 'status_id' => '', + 'filter' => json_encode($filter), + 'search' => '', + 'sort' => 'id', + 'order' => 'asc', + 'offset' => '0', + 'limit' => '50', + ]) + ) + ->assertOk() + ->assertJsonStructure([ + 'total', + 'rows', + ]) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 2)->etc()) + ->assertJsonFragment([ + 'id' => $assetA->id, + ]) + ->assertJsonFragment([ + 'id' => $assetB->id, + ]); + } + + public function testFilterAssetsSupplierString(): void + { + $supplierA = Supplier::factory()->create(); + $supplierB = Supplier::factory()->create(); + + // Assets + $assetA = Asset::factory()->create(['supplier_id' => $supplierA->id]); + $assetB = Asset::factory()->create(['supplier_id' => $supplierB->id]); + + $filter = [ + [ + 'field' => 'supplier', + 'value' => [$supplierA->id], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $this->actingAsForApi(User::factory()->superuser()->create()) + ->getJson( + route('api.assets.index', [ + 'status' => '', + 'order_number' => '', + 'company_id' => '', + 'status_id' => '', + 'filter' => json_encode($filter), + 'search' => '', + 'sort' => 'id', + 'order' => 'asc', + 'offset' => '0', + 'limit' => '50', + ]) + ) + ->assertOk() + ->assertJsonStructure([ + 'total', + 'rows', + ]) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 1)->etc()) + ->assertJsonFragment([ + 'id' => $assetA->id, + ]); + } + + public function testFilterAssetsSupplierArray(): void + { + $supplierA = Supplier::factory()->create(); + $supplierB = Supplier::factory()->create(); + $supplierC = Supplier::factory()->create(); + + $assetA = Asset::factory()->create(['supplier_id' => $supplierA->id]); + $assetB = Asset::factory()->create(['supplier_id' => $supplierB->id]); + $assetC = Asset::factory()->create(['supplier_id' => $supplierC->id]); + + $filter = [ + [ + 'field' => 'supplier', + 'value' => [$supplierA->id, $supplierC->id], + 'operator' => 'contains', + 'logic' => 'AND', + ], + ]; + + $this->actingAsForApi(User::factory()->superuser()->create()) + ->getJson( + route('api.assets.index', [ + 'status' => '', + 'order_number' => '', + 'company_id' => '', + 'status_id' => '', + 'filter' => json_encode($filter), + 'search' => '', + 'sort' => 'id', + 'order' => 'asc', + 'offset' => '0', + 'limit' => '50', + ]) + ) + ->assertOk() + ->assertJsonStructure([ + 'total', + 'rows', + ]) + ->assertJson(fn(AssertableJson $json) => $json->has('rows', 2)->etc()) + ->assertJsonFragment([ + 'id' => $assetA->id, + ]) + ->assertJsonFragment([ + 'id' => $assetC->id, + ]); + } +} diff --git a/tests/Feature/AssetQuery/AssetTagQueryTest.php b/tests/Feature/AssetQuery/AssetTagQueryTest.php new file mode 100644 index 000000000000..3da297d2667a --- /dev/null +++ b/tests/Feature/AssetQuery/AssetTagQueryTest.php @@ -0,0 +1,97 @@ +create(['asset_tag' => '1']); + $assetB = Asset::factory()->create(['asset_tag' => '2']); + $assetC = Asset::factory()->create(['asset_tag' => '21']); + $assetD = Asset::factory()->create(['asset_tag' => '42']); + + $filter = [ + [ + 'field' => 'asset_tag', + 'value' => ['1'], + 'operator' => 'equals', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + } + + + public function testFilterAssetTagAndContains() + { + $assetA = Asset::factory()->create(['asset_tag' => '1']); + $assetB = Asset::factory()->create(['asset_tag' => '2']); + $assetC = Asset::factory()->create(['asset_tag' => '21']); + $assetD = Asset::factory()->create(['asset_tag' => '42']); + + $filter = [ + [ + 'field' => 'asset_tag', + 'value' => ['1'], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(2, $results); + $this->assertTrue($results->contains($assetA)); + } + + public function testFilterAssetTagNotEquals() + { + $assetA = Asset::factory()->create(['asset_tag' => '1']); + $assetB = Asset::factory()->create(['asset_tag' => '2']); + $assetC = Asset::factory()->create(['asset_tag' => '21']); + $assetD = Asset::factory()->create(['asset_tag' => '42']); + + $filter = [[ + 'field' => 'asset_tag', + 'value' => ['1'], + 'operator' => 'equals', + 'logic' => 'NOT', + ]]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(3, $results); + $this->assertTrue($results->contains($assetB)); + $this->assertTrue($results->contains($assetC)); + $this->assertTrue($results->contains($assetD)); + } + + public function testFilterAssetTagNotContains() + { + $assetA = Asset::factory()->create(['asset_tag' => '1']); + $assetB = Asset::factory()->create(['asset_tag' => '2']); + $assetC = Asset::factory()->create(['asset_tag' => '21']); + $assetD = Asset::factory()->create(['asset_tag' => '42']); + + $filter = [[ + 'field' => 'asset_tag', + 'value' => ['1'], + 'operator' => 'contains', + 'logic' => 'NOT', + ]]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(2, $results); + $this->assertTrue($results->contains($assetB)); + $this->assertTrue($results->contains($assetD)); + } +} \ No newline at end of file diff --git a/tests/Feature/AssetQuery/AssignedToAssetQueryTest.php b/tests/Feature/AssetQuery/AssignedToAssetQueryTest.php new file mode 100644 index 000000000000..34afe56f63ee --- /dev/null +++ b/tests/Feature/AssetQuery/AssignedToAssetQueryTest.php @@ -0,0 +1,461 @@ +create(['name' => 'Server']); + $parentAssetB = Asset::factory()->create(['name' => 'Desktop']); + + $assetA = Asset::factory()->create(['assigned_type' => Asset::class, 'assigned_to' => $parentAssetA->id]); + $assetB = Asset::factory()->create(['assigned_type' => Asset::class, 'assigned_to' => $parentAssetB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => Asset::class, + 'value' => '' + ], + 'operator' => 'equals', + 'logic' => 'AND' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(4, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + $this->assertTrue($results->contains($parentAssetA)); + $this->assertTrue($results->contains($parentAssetB)); + } + public function testFilterAssetAssignedToAssetNotEqualsEmpty() + { + $parentAssetA = Asset::factory()->create(['name' => 'Server']); + $parentAssetB = Asset::factory()->create(['name' => 'Desktop']); + + $assetA = Asset::factory()->create(['assigned_type' => Asset::class, 'assigned_to' => $parentAssetA->id]); + $assetB = Asset::factory()->create(['assigned_type' => Asset::class, 'assigned_to' => $parentAssetB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => Asset::class, + 'value' => '' + ], + 'operator' => 'equals', + 'logic' => 'NOT' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(2, $results); + $this->assertFalse($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + $this->assertTrue($results->contains($parentAssetA)); + $this->assertTrue($results->contains($parentAssetB)); + } + public function testFilterAssetAssignedToAssetAndContainsEmpty() + { + $parentAssetA = Asset::factory()->create(['name' => 'Server']); + $parentAssetB = Asset::factory()->create(['name' => 'Desktop']); + + $assetA = Asset::factory()->create(['assigned_type' => Asset::class, 'assigned_to' => $parentAssetA->id]); + $assetB = Asset::factory()->create(['assigned_type' => Asset::class, 'assigned_to' => $parentAssetB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => Asset::class, + 'value' => '' + ], + 'operator' => 'contains', + 'logic' => 'AND' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(4, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + $this->assertTrue($results->contains($parentAssetA)); + $this->assertTrue($results->contains($parentAssetB)); + } + public function testFilterAssetAssignedToAssetNotContainsEmpty() + { + $parentAssetA = Asset::factory()->create(['name' => 'Server']); + $parentAssetB = Asset::factory()->create(['name' => 'Desktop']); + + $assetA = Asset::factory()->create(['assigned_type' => Asset::class, 'assigned_to' => $parentAssetA->id]); + $assetB = Asset::factory()->create(['assigned_type' => Asset::class, 'assigned_to' => $parentAssetB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => Asset::class, + 'value' => '' + ], + 'operator' => 'contains', + 'logic' => 'NOT' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(2, $results); + $this->assertFalse($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + $this->assertTrue($results->contains($parentAssetA)); + $this->assertTrue($results->contains($parentAssetB)); + } + + /* + * Equals and not equals + */ + + public function testFilterAssetAssignedToAssetNameAndEquals() + { + $parentAssetA = Asset::factory()->create(['asset_tag' => 'pc01', 'name' => 'Server']); + $parentAssetB = Asset::factory()->create(['asset_tag' => 'srv01', 'name' => 'Desktop']); + + $assetA = Asset::factory()->create(['assigned_type' => Asset::class, 'assigned_to' => $parentAssetA->id]); + $assetB = Asset::factory()->create(['assigned_type' => Asset::class, 'assigned_to' => $parentAssetB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => Asset::class, + 'value' => $parentAssetA->name + ], + 'operator' => 'equals', + 'logic' => 'AND' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + $this->assertFalse($results->contains($parentAssetA)); + $this->assertFalse($results->contains($parentAssetB)); + } + + public function testFilterAssetAssignedToAssetTagAndEquals() + { + $parentAssetA = Asset::factory()->create(['asset_tag' => 'pc01', 'name' => 'Server']); + $parentAssetB = Asset::factory()->create(['asset_tag' => 'srv01', 'name' => 'Desktop']); + + $assetA = Asset::factory()->create(['assigned_type' => Asset::class, 'assigned_to' => $parentAssetA->id]); + $assetB = Asset::factory()->create(['assigned_type' => Asset::class, 'assigned_to' => $parentAssetB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => Asset::class, + 'value' => $parentAssetA->asset_tag + ], + 'operator' => 'equals', + 'logic' => 'AND' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + $this->assertFalse($results->contains($parentAssetA)); + $this->assertFalse($results->contains($parentAssetB)); + } + + public function testFilterAssetAssignedToAssetNameNotEquals() + { + $parentAssetA = Asset::factory()->create(['asset_tag' => 'pc01', 'name' => 'Server']); + $parentAssetB = Asset::factory()->create(['asset_tag' => 'srv01', 'name' => 'Desktop']); + + $assetA = Asset::factory()->create(['assigned_type' => Asset::class, 'assigned_to' => $parentAssetA->id]); + $assetB = Asset::factory()->create(['assigned_type' => Asset::class, 'assigned_to' => $parentAssetB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => Asset::class, + 'value' => $parentAssetA->name + ], + 'operator' => 'equals', + 'logic' => 'NOT' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(3, $results); + $this->assertFalse($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + $this->assertTrue($results->contains($parentAssetA)); + $this->assertTrue($results->contains($parentAssetB)); + } + + public function testFilterAssetAssignedToAssetTagNotEquals() + { + $parentAssetA = Asset::factory()->create(['asset_tag' => 'pc01', 'name' => 'Server']); + $parentAssetB = Asset::factory()->create(['asset_tag' => 'srv01', 'name' => 'Desktop']); + + $assetA = Asset::factory()->create(['assigned_type' => Asset::class, 'assigned_to' => $parentAssetA->id]); + $assetB = Asset::factory()->create(['assigned_type' => Asset::class, 'assigned_to' => $parentAssetB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => Asset::class, + 'value' => $parentAssetA->asset_tag + ], + 'operator' => 'equals', + 'logic' => 'NOT' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(3, $results); + $this->assertFalse($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + $this->assertTrue($results->contains($parentAssetA)); + $this->assertTrue($results->contains($parentAssetB)); + } + + /* + * Contains and not contains + */ + + public function testFilterAssetAssignedToAssetNameAndContainsComplete() + { + $parentAssetA = Asset::factory()->create(['asset_tag' => 'pc01', 'name' => 'Server']); + $parentAssetB = Asset::factory()->create(['asset_tag' => 'srv01', 'name' => 'Desktop']); + + $assetA = Asset::factory()->create(['assigned_type' => Asset::class, 'assigned_to' => $parentAssetA->id]); + $assetB = Asset::factory()->create(['assigned_type' => Asset::class, 'assigned_to' => $parentAssetB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => Asset::class, + 'value' => $parentAssetA->name + ], + 'operator' => 'contains', + 'logic' => 'AND' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + $this->assertFalse($results->contains($parentAssetA)); + $this->assertFalse($results->contains($parentAssetB)); + } + + public function testFilterAssetAssignedToAssetNameAndContainsPartital() + { + $parentAssetA = Asset::factory()->create(['asset_tag' => 'pc01', 'name' => 'Server']); + $parentAssetB = Asset::factory()->create(['asset_tag' => 'srv01', 'name' => 'Desktop']); + + $assetA = Asset::factory()->create(['assigned_type' => Asset::class, 'assigned_to' => $parentAssetA->id]); + $assetB = Asset::factory()->create(['assigned_type' => Asset::class, 'assigned_to' => $parentAssetB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => Asset::class, + 'value' => 'pc' + ], + 'operator' => 'contains', + 'logic' => 'AND' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + $this->assertFalse($results->contains($parentAssetA)); + $this->assertFalse($results->contains($parentAssetB)); + } + + public function testFilterAssetAssignedToAssetTagAndContainsComplete() + { + $parentAssetA = Asset::factory()->create(['asset_tag' => 'pc01', 'name' => 'Server']); + $parentAssetB = Asset::factory()->create(['asset_tag' => 'srv01', 'name' => 'Desktop']); + + $assetA = Asset::factory()->create(['assigned_type' => Asset::class, 'assigned_to' => $parentAssetA->id]); + $assetB = Asset::factory()->create(['assigned_type' => Asset::class, 'assigned_to' => $parentAssetB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => Asset::class, + 'value' => $parentAssetA->asset_tag + ], + 'operator' => 'equals', + 'logic' => 'contains' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + $this->assertFalse($results->contains($parentAssetA)); + $this->assertFalse($results->contains($parentAssetB)); + } + + public function testFilterAssetAssignedToAssetTagAndContainsPartial() + { + $parentAssetA = Asset::factory()->create(['asset_tag' => 'pc01', 'name' => 'Server']); + $parentAssetB = Asset::factory()->create(['asset_tag' => 'srv01', 'name' => 'Desktop']); + + $assetA = Asset::factory()->create(['assigned_type' => Asset::class, 'assigned_to' => $parentAssetA->id]); + $assetB = Asset::factory()->create(['assigned_type' => Asset::class, 'assigned_to' => $parentAssetB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => Asset::class, + 'value' => 'pc' + ], + 'operator' => 'contains', + 'logic' => 'AND' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + $this->assertFalse($results->contains($parentAssetA)); + $this->assertFalse($results->contains($parentAssetB)); + } + + public function testFilterAssetAssignedToAssetNameNotContainsComplete() + { + $parentAssetA = Asset::factory()->create(['asset_tag' => 'pc01', 'name' => 'Server']); + $parentAssetB = Asset::factory()->create(['asset_tag' => 'srv01', 'name' => 'Desktop']); + + $assetA = Asset::factory()->create(['assigned_type' => Asset::class, 'assigned_to' => $parentAssetA->id]); + $assetB = Asset::factory()->create(['assigned_type' => Asset::class, 'assigned_to' => $parentAssetB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => Asset::class, + 'value' => $parentAssetA->name + ], + 'operator' => 'contains', + 'logic' => 'NOT' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(3, $results); + $this->assertFalse($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + $this->assertTrue($results->contains($parentAssetA)); + $this->assertTrue($results->contains($parentAssetB)); + } + + public function testFilterAssetAssignedToAssetNameNotContainsPartial() + { + $parentAssetA = Asset::factory()->create(['asset_tag' => 'pc01', 'name' => 'Server']); + $parentAssetB = Asset::factory()->create(['asset_tag' => 'srv01', 'name' => 'Desktop']); + + $assetA = Asset::factory()->create(['assigned_type' => Asset::class, 'assigned_to' => $parentAssetA->id]); + $assetB = Asset::factory()->create(['assigned_type' => Asset::class, 'assigned_to' => $parentAssetB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => Asset::class, + 'value' => 'pc' + ], + 'operator' => 'contains', + 'logic' => 'NOT' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(3, $results); + $this->assertFalse($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + $this->assertTrue($results->contains($parentAssetA)); + $this->assertTrue($results->contains($parentAssetB)); + } + + public function testFilterAssetAssignedToAssetTagNotContainsComplete() + { + $parentAssetA = Asset::factory()->create(['asset_tag' => 'pc01', 'name' => 'Server']); + $parentAssetB = Asset::factory()->create(['asset_tag' => 'srv01', 'name' => 'Desktop']); + + $assetA = Asset::factory()->create(['assigned_type' => Asset::class, 'assigned_to' => $parentAssetA->id]); + $assetB = Asset::factory()->create(['assigned_type' => Asset::class, 'assigned_to' => $parentAssetB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => Asset::class, + 'value' => $parentAssetA->asset_tag + ], + 'operator' => 'contains', + 'logic' => 'NOT' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(3, $results); + $this->assertFalse($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + $this->assertTrue($results->contains($parentAssetA)); + $this->assertTrue($results->contains($parentAssetB)); + } + + public function testFilterAssetAssignedToAssetTagNotContainsPartial() + { + $parentAssetA = Asset::factory()->create(['asset_tag' => 'pc01', 'name' => 'Server']); + $parentAssetB = Asset::factory()->create(['asset_tag' => 'srv01', 'name' => 'Desktop']); + + $assetA = Asset::factory()->create(['assigned_type' => Asset::class, 'assigned_to' => $parentAssetA->id]); + $assetB = Asset::factory()->create(['assigned_type' => Asset::class, 'assigned_to' => $parentAssetB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => Asset::class, + 'value' => 'pc' + ], + 'operator' => 'contains', + 'logic' => 'NOT' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(3, $results); + $this->assertFalse($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + $this->assertTrue($results->contains($parentAssetA)); + $this->assertTrue($results->contains($parentAssetB)); + } + +} \ No newline at end of file diff --git a/tests/Feature/AssetQuery/AssignedToLocationQueryTest.php b/tests/Feature/AssetQuery/AssignedToLocationQueryTest.php new file mode 100644 index 000000000000..78f29d042039 --- /dev/null +++ b/tests/Feature/AssetQuery/AssignedToLocationQueryTest.php @@ -0,0 +1,278 @@ +create(['name' => 'Oslo']); + $locationB = Location::factory()->create(['name' => 'Helsinki']); + + $assetA = Asset::factory()->create(['assigned_type' => Location::class, 'assigned_to' => $locationA->id]); + $assetB = Asset::factory()->create(['assigned_type' => Location::class, 'assigned_to' => $locationB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => Location::class, + 'value' => '' + ], + 'operator' => 'equals', + 'logic' => 'AND' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(2, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + } + public function testFilterAssetAssignedToLocationNotEqualsEmpty() + { + $locationA = Location::factory()->create(['name' => 'Oslo']); + $locationB = Location::factory()->create(['name' => 'Helsinki']); + + $assetA = Asset::factory()->create(['assigned_type' => Location::class, 'assigned_to' => $locationA->id]); + $assetB = Asset::factory()->create(['assigned_type' => Location::class, 'assigned_to' => $locationB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => Location::class, + 'value' => '' + ], + 'operator' => 'equals', + 'logic' => 'NOT' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(0, $results); + $this->assertFalse($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + } + public function testFilterAssetAssignedToLocationAndContainsEmpty() + { + $locationA = Location::factory()->create(['name' => 'Oslo']); + $locationB = Location::factory()->create(['name' => 'Helsinki']); + + $assetA = Asset::factory()->create(['assigned_type' => Location::class, 'assigned_to' => $locationA->id]); + $assetB = Asset::factory()->create(['assigned_type' => Location::class, 'assigned_to' => $locationB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => Location::class, + 'value' => '' + ], + 'operator' => 'contains', + 'logic' => 'AND' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(2, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + } + public function testFilterAssetAssignedToLocationNotContainsEmpty() + { + $locationA = Location::factory()->create(['name' => 'Oslo']); + $locationB = Location::factory()->create(['name' => 'Helsinki']); + + $assetA = Asset::factory()->create(['assigned_type' => Location::class, 'assigned_to' => $locationA->id]); + $assetB = Asset::factory()->create(['assigned_type' => Location::class, 'assigned_to' => $locationB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => Location::class, + 'value' => '' + ], + 'operator' => 'contains', + 'logic' => 'NOT' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(0, $results); + $this->assertFalse($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + } + + /* + * Equals and not equals + */ + + public function testFilterAssetAssignedToLocationAndEquals() + { + $locationA = Location::factory()->create(['name' => 'Oslo']); + $locationB = Location::factory()->create(['name' => 'Helsinki']); + + $assetA = Asset::factory()->create(['assigned_type' => Location::class, 'assigned_to' => $locationA->id]); + $assetB = Asset::factory()->create(['assigned_type' => Location::class, 'assigned_to' => $locationB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => Location::class, + 'value' => $locationA->name + ], + 'operator' => 'equals', + 'logic' => 'AND' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + } + + public function testFilterAssetAssignedToLocationNotEquals() + { + $locationA = Location::factory()->create(['name' => 'Oslo']); + $locationB = Location::factory()->create(['name' => 'Helsinki']); + + $assetA = Asset::factory()->create(['assigned_type' => Location::class, 'assigned_to' => $locationA->id]); + $assetB = Asset::factory()->create(['assigned_type' => Location::class, 'assigned_to' => $locationB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => Location::class, + 'value' => $locationA->name + ], + 'operator' => 'equals', + 'logic' => 'NOT' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(1, $results); + $this->assertFalse($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + } + + /* + * Contains and not contains + */ + + public function testFilterAssetAssignedToLocationAndContainsPartial() + { + $locationA = Location::factory()->create(['name' => 'Oslo']); + $locationB = Location::factory()->create(['name' => 'Helsinki']); + + $assetA = Asset::factory()->create(['assigned_type' => Location::class, 'assigned_to' => $locationA->id]); + $assetB = Asset::factory()->create(['assigned_type' => Location::class, 'assigned_to' => $locationB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => Location::class, + 'value' => 'Hel' + ], + 'operator' => 'contains', + 'logic' => 'AND' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(1, $results); + $this->assertFalse($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + } + + public function testFilterAssetAssignedToLocationAndContainsComplete() + { + $locationA = Location::factory()->create(['name' => 'Oslo']); + $locationB = Location::factory()->create(['name' => 'Helsinki']); + + $assetA = Asset::factory()->create(['assigned_type' => Location::class, 'assigned_to' => $locationA->id]); + $assetB = Asset::factory()->create(['assigned_type' => Location::class, 'assigned_to' => $locationB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => Location::class, + 'value' => $locationA->name + ], + 'operator' => 'contains', + 'logic' => 'AND' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + } + + public function testFilterAssetAssignedToLocationNotContainsPartial() + { + $locationA = Location::factory()->create(['name' => 'Oslo']); + $locationB = Location::factory()->create(['name' => 'Helsinki']); + + $assetA = Asset::factory()->create(['assigned_type' => Location::class, 'assigned_to' => $locationA->id]); + $assetB = Asset::factory()->create(['assigned_type' => Location::class, 'assigned_to' => $locationB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => Location::class, + 'value' => 'Hel' + ], + 'operator' => 'contains', + 'logic' => 'NOT' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + } + + public function testFilterAssetAssignedToLocationNotContainsComplete() + { + $locationA = Location::factory()->create(['name' => 'Oslo']); + $locationB = Location::factory()->create(['name' => 'Helsinki']); + + $assetA = Asset::factory()->create(['assigned_type' => Location::class, 'assigned_to' => $locationA->id]); + $assetB = Asset::factory()->create(['assigned_type' => Location::class, 'assigned_to' => $locationB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => Location::class, + 'value' => $locationA->name + ], + 'operator' => 'contains', + 'logic' => 'NOT' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(1, $results); + $this->assertFalse($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + } + +} \ No newline at end of file diff --git a/tests/Feature/AssetQuery/AssignedToUserQueryTest.php b/tests/Feature/AssetQuery/AssignedToUserQueryTest.php new file mode 100644 index 000000000000..4a53e64fc544 --- /dev/null +++ b/tests/Feature/AssetQuery/AssignedToUserQueryTest.php @@ -0,0 +1,576 @@ +create(['first_name' => 'Snaggrit', 'last_name' => 'Filthsnout']); + $userB = User::factory()->create(['first_name' => 'Klikpik', 'last_name' => 'Rustfingers']); + + $assetA = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userA->id]); + $assetB = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => User::class, + 'value' => '' + ], + 'operator' => 'equals', + 'logic' => 'AND' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(2, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + } + public function testFilterAssetAssignedToUserNotEqualsEmpty() + { + $userA = User::factory()->create(['first_name' => 'Grubnash', 'last_name' => 'Wormchewer']); + $userB = User::factory()->create(['first_name' => 'Vriggle', 'last_name' => 'Mudspine']); + + $assetA = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userA->id]); + $assetB = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => User::class, + 'value' => '' + ], + 'operator' => 'equals', + 'logic' => 'NOT' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(0, $results); + $this->assertFalse($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + } + public function testFilterAssetAssignedToUserAndContainsEmpty() + { + $userA = User::factory()->create(['first_name' => 'Grubnash', 'last_name' => 'Wormchewer']); + $userB = User::factory()->create(['first_name' => 'Vriggle', 'last_name' => 'Mudspine']); + + $assetA = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userA->id]); + $assetB = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => User::class, + 'value' => '' + ], + 'operator' => 'contains', + 'logic' => 'AND' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(2, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + } + public function testFilterAssetAssignedToAssetNotContainsEmpty() + { + $userA = User::factory()->create(['first_name' => 'Mucksnip', 'last_name' => 'Rotfoot']); + $userB = User::factory()->create(['first_name' => 'Dregzit', 'last_name' => 'Spleenbiter']); + + $assetA = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userA->id]); + $assetB = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => User::class, + 'value' => '' + ], + 'operator' => 'contains', + 'logic' => 'NOT' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(0, $results); + $this->assertFalse($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + } + + /* + * Equals and not equals + */ + public function testFilterAssetAssignedToUserAndEqualsFirstName() + { + $userA = User::factory()->create(['first_name' => 'Snortblix', 'last_name' => 'Ashclatter']); + $userB = User::factory()->create(['first_name' => 'Kribba', 'last_name' => 'Scrapstitch']); + + $assetA = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userA->id]); + $assetB = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => User::class, + 'value' => $userA->first_name, + ], + 'operator' => 'equals', + 'logic' => 'AND' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + } + + public function testFilterAssetAssignedToUserAndEqualsLastName() + { + $userA = User::factory()->create(['first_name' => 'Zogmuk', 'last_name' => 'Gutflinger']); + $userB = User::factory()->create(['first_name' => 'Trigglewort', 'last_name' => 'Nailgnaw']); + + $assetA = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userA->id]); + $assetB = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => User::class, + 'value' => $userB->last_name, + ], + 'operator' => 'equals', + 'logic' => 'AND' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(1, $results); + $this->assertFalse($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + } + + public function testFilterAssetAssignedToUserAndEqualsCompleteName() + { + $userA = User::factory()->create(['first_name' => 'Gritznab', 'last_name' => 'Smudgeclaw']); + $userB = User::factory()->create(['first_name' => 'Pibbsnark', 'last_name' => 'Ratpinch']); + + $assetA = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userA->id]); + $assetB = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => User::class, + 'value' => $userB->first_name . ' ' . $userB->last_name, + ], + 'operator' => 'equals', + 'logic' => 'AND' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(1, $results); + $this->assertFalse($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + } + + public function testFilterAssetAssignedToUserNotEqualsFirstName() + { + $userA = User::factory()->create(['first_name' => 'Snortblix', 'last_name' => 'Ashclatter']); + $userB = User::factory()->create(['first_name' => 'Kribba', 'last_name' => 'Scrapstitch']); + + $assetA = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userA->id]); + $assetB = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => User::class, + 'value' => $userA->first_name, + ], + 'operator' => 'equals', + 'logic' => 'NOT' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(1, $results); + $this->assertFalse($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + } + + public function testFilterAssetAssignedToUserNotEqualsLastName() + { + $userA = User::factory()->create(['first_name' => 'Zogmuk', 'last_name' => 'Gutflinger']); + $userB = User::factory()->create(['first_name' => 'Trigglewort', 'last_name' => 'Nailgnaw']); + + $assetA = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userA->id]); + $assetB = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => User::class, + 'value' => $userB->last_name, + ], + 'operator' => 'equals', + 'logic' => 'NOT' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + } + + public function testFilterAssetAssignedToUserNotEqualsCompleteName() + { + $userA = User::factory()->create(['first_name' => 'Gritznab', 'last_name' => 'Smudgeclaw']); + $userB = User::factory()->create(['first_name' => 'Pibbsnark', 'last_name' => 'Ratpinch']); + + $assetA = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userA->id]); + $assetB = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => User::class, + 'value' => $userB->first_name . ' ' . $userB->last_name, + ], + 'operator' => 'equals', + 'logic' => 'NOT' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + } + + /* + * Contains and not contains + */ + public function testFilterAssetAssignedToUserAndContainsFirstNameComplete() + { + $userA = User::factory()->create(['first_name' => 'Rattlegrub', 'last_name' => 'Twigsneer']); + $userB = User::factory()->create(['first_name' => 'Skivvix', 'last_name' => 'Bleakgrin']); + + $assetA = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userA->id]); + $assetB = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => User::class, + 'value' => $userA->first_name, + ], + 'operator' => 'contains', + 'logic' => 'AND' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + } + + public function testFilterAssetAssignedToUserAndContainsFirstNamePartial() + { + $userA = User::factory()->create(['first_name' => 'Rattlegrub', 'last_name' => 'Twigsneer']); + $userB = User::factory()->create(['first_name' => 'Skivvix', 'last_name' => 'Bleakgrin']); + + $assetA = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userA->id]); + $assetB = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => User::class, + 'value' => 'grub', + ], + 'operator' => 'contains', + 'logic' => 'AND' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + } + + public function testFilterAssetAssignedToUserAndContainsLastNameComplete() + { + $userA = User::factory()->create(['first_name' => 'Hobznok', 'last_name' => 'Nailspitter']); + $userB = User::factory()->create(['first_name' => 'Nibblit', 'last_name' => 'Grimepocket']); + + $assetA = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userA->id]); + $assetB = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => User::class, + 'value' => $userB->last_name, + ], + 'operator' => 'contains', + 'logic' => 'AND' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(1, $results); + $this->assertFalse($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + } + + public function testFilterAssetAssignedToUserAndContainsLastNamePartial() + { + $userA = User::factory()->create(['first_name' => 'Hobznok', 'last_name' => 'Nailspitter']); + $userB = User::factory()->create(['first_name' => 'Nibblit', 'last_name' => 'Grimepocket']); + + $assetA = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userA->id]); + $assetB = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => User::class, + 'value' => 'pocket', + ], + 'operator' => 'contains', + 'logic' => 'AND' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(1, $results); + $this->assertFalse($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + } + + public function testFilterAssetAssignedToUserAndContainsCompleteNameComplete() + { + $userA = User::factory()->create(['first_name' => 'Gorpzack', 'last_name' => 'Sootsnort']); + $userB = User::factory()->create(['first_name' => 'Skratcha', 'last_name' => 'Funguspike']); + + $assetA = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userA->id]); + $assetB = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => User::class, + 'value' => $userB->first_name . ' ' . $userB->last_name, + ], + 'operator' => 'contains', + 'logic' => 'AND' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(1, $results); + $this->assertFalse($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + } + + public function testFilterAssetAssignedToUserAndContainsCompleteNamePartial() + { + $userA = User::factory()->create(['first_name' => 'Gorpzack', 'last_name' => 'Sootsnort']); + $userB = User::factory()->create(['first_name' => 'Skratcha', 'last_name' => 'Funguspike']); + + $assetA = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userA->id]); + $assetB = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => User::class, + 'value' => 'cha fun', + ], + 'operator' => 'contains', + 'logic' => 'AND' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(1, $results); + $this->assertFalse($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + } + + public function testFilterAssetAssignedToUserNotContainsFirstNameComplete() + { + $userA = User::factory()->create(['first_name' => 'Fizzgrub', 'last_name' => 'Sproingjaw']); + $userB = User::factory()->create(['first_name' => 'Blortwig', 'last_name' => 'Shankspark']); + + $assetA = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userA->id]); + $assetB = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => User::class, + 'value' => $userA->first_name, + ], + 'operator' => 'contains', + 'logic' => 'NOT' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(1, $results); + $this->assertFalse($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + } + + public function testFilterAssetAssignedToUserNotContainsFirstNamePartial() + { + $userA = User::factory()->create(['first_name' => 'Fizzgrub', 'last_name' => 'Sproingjaw']); + $userB = User::factory()->create(['first_name' => 'Blortwig', 'last_name' => 'Shankspark']); + + $assetA = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userA->id]); + $assetB = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => User::class, + 'value' => 'fizz', + ], + 'operator' => 'contains', + 'logic' => 'NOT' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(1, $results); + $this->assertFalse($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + } + + public function testFilterAssetAssignedToUserNotContainsLastNameComplete() + { + $userA = User::factory()->create(['first_name' => 'Krakstik', 'last_name' => 'Filchmask']); + $userB = User::factory()->create(['first_name' => 'Splugwort', 'last_name' => 'Mosscackle']); + + $assetA = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userA->id]); + $assetB = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => User::class, + 'value' => $userB->last_name, + ], + 'operator' => 'contains', + 'logic' => 'NOT' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + } + + public function testFilterAssetAssignedToUserNotContainsLastNamePartial() + { + $userA = User::factory()->create(['first_name' => 'Krakstik', 'last_name' => 'Filchmask']); + $userB = User::factory()->create(['first_name' => 'Splugwort', 'last_name' => 'Mosscackle']); + + $assetA = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userA->id]); + $assetB = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => User::class, + 'value' => 'kle', + ], + 'operator' => 'contains', + 'logic' => 'NOT' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + } + + public function testFilterAssetAssignedToUserNotContainsCompleteNameComplete() + { + $userA = User::factory()->create(['first_name' => 'Vibblesnap', 'last_name' => 'Tangletoe']); + $userB = User::factory()->create(['first_name' => 'Grobnix', 'last_name' => 'Smeltwhisk']); + + $assetA = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userA->id]); + $assetB = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => User::class, + 'value' => $userB->first_name . ' ' . $userB->last_name, + ], + 'operator' => 'contains', + 'logic' => 'NOT' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + } + + public function testFilterAssetAssignedToUserNotContainsCompleteNamePartial() + { + $userA = User::factory()->create(['first_name' => 'Vibblesnap', 'last_name' => 'Tangletoe']); + $userB = User::factory()->create(['first_name' => 'Grobnix', 'last_name' => 'Smeltwhisk']); + + $assetA = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userA->id]); + $assetB = Asset::factory()->create(['assigned_type' => User::class, 'assigned_to' => $userB->id]); + + $filter = [ + [ + 'field' => 'assigned_to', + 'value' => [ + 'type' => User::class, + 'value' => 'nix sme', + ], + 'operator' => 'contains', + 'logic' => 'NOT' + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + } + +} \ No newline at end of file diff --git a/tests/Feature/AssetQuery/CategoryQueryTest.php b/tests/Feature/AssetQuery/CategoryQueryTest.php new file mode 100644 index 000000000000..2eff33cd5b54 --- /dev/null +++ b/tests/Feature/AssetQuery/CategoryQueryTest.php @@ -0,0 +1,269 @@ +create(); + $categoryB = Category::factory()->create(); + + $modelA = AssetModel::factory()->create(['category_id' => $categoryA->id]); + $modelB = AssetModel::factory()->create(['category_id' => $categoryB->id]); + + // Assets + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + + $filter = [ + [ + 'field' => 'category', + 'value' => [''], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(2, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + } + + public function testFilterAssetCategoryStringComplete() + { + $categoryA = Category::factory()->create(); + $categoryB = Category::factory()->create(); + + $modelA = AssetModel::factory()->create(['category_id' => $categoryA->id]); + $modelB = AssetModel::factory()->create(['category_id' => $categoryB->id]); + + // Assets + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + + $filter = [ + [ + 'field' => 'category', + 'value' => [$categoryA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + } + + public function testFilterAssetCategoryStringPartial() + { + $categoryA = Category::factory()->create(); + $categoryB = Category::factory()->create(); + + $modelA = AssetModel::factory()->create(['category_id' => $categoryA->id]); + $modelB = AssetModel::factory()->create(['category_id' => $categoryB->id]); + + // Assets + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + + $queryString = CategoryQueryTest::getExtendedPrefix($categoryA->name, $categoryB->name); + + $filter = [ + [ + 'field' => 'category', + 'value' => [$queryString], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + } + + public function testFilterAssetCategoryArraySingle() + { + + $categoryA = Category::factory()->create(); + $categoryB = Category::factory()->create(); + + $modelA = AssetModel::factory()->create(['category_id' => $categoryA->id]); + $modelB = AssetModel::factory()->create(['category_id' => $categoryB->id]); + + // Assets + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + + $filter = [ + [ + 'field' => 'category', + 'value' => [$categoryA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + + } + + public function testFilterAssetCategoryArrayMultiple() + { + + $categoryA = Category::factory()->create(); + $categoryB = Category::factory()->create(); + $categoryC = Category::factory()->create(); + $categoryD = Category::factory()->create(); + $categoryE = Category::factory()->create(); + + $modelA = AssetModel::factory()->create(['category_id' => $categoryA->id]); + $modelB = AssetModel::factory()->create(['category_id' => $categoryB->id]); + $modelC = AssetModel::factory()->create(['category_id' => $categoryC->id]); + $modelD = AssetModel::factory()->create(['category_id' => $categoryD->id]); + $modelE = AssetModel::factory()->create(['category_id' => $categoryE->id]); + + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + $assetC = Asset::factory()->create(['model_id' => $modelC->id]); + $assetD = Asset::factory()->create(['model_id' => $modelD->id]); + $assetE = Asset::factory()->create(['model_id' => $modelE->id]); + + // When: Query with an array of names + $filter = [ + [ + 'field' => 'category', + 'value' => [$categoryB->id, $categoryE->id], + 'operator' => 'contains', + 'logic' => 'AND', + ], + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + // Then: Should include only assetA to assetD + $this->assertCount(2, $results); + $this->assertTrue($results->contains($assetB)); + $this->assertTrue($results->contains($assetE)); + $this->assertFalse($results->contains($assetA)); + $this->assertFalse($results->contains($assetC)); + $this->assertFalse($results->contains($assetD)); + + } + + public function testFilterAssetAssignedToCategoryId() + { + + $categoryA = Category::factory()->create(); + $categoryB = Category::factory()->create(); + + $modelA = AssetModel::factory()->create(['category_id' => $categoryA->id]); + $modelB = AssetModel::factory()->create(['category_id' => $categoryB->id]); + + // Assets + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + + $filter = [ + [ + 'field' => 'category', + 'value' => [$categoryA->id], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + + } + + public function testFilterAssetAssignedToCategoryIdArraySingle() + { + + $categoryA = Category::factory()->create(); + $categoryB = Category::factory()->create(); + + $modelA = AssetModel::factory()->create(['category_id' => $categoryA->id]); + $modelB = AssetModel::factory()->create(['category_id' => $categoryB->id]); + + // Assets + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + + $filter = [ + [ + 'field' => 'category', + 'value' => [$categoryA->id], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + + } + + public function testFilterAssetAssignedToCategoryIdAndNameArray() + { + $categoryA = Category::factory()->create(); + $categoryB = Category::factory()->create(); + $categoryC = Category::factory()->create(); + + $modelA = AssetModel::factory()->create(['category_id' => $categoryA->id]); + $modelB = AssetModel::factory()->create(['category_id' => $categoryB->id]); + $modelC = AssetModel::factory()->create(['category_id' => $categoryC->id]); + + // Assets + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + $assetC = Asset::factory()->create(['model_id' => $modelC->id]); + + $filter = [ + [ + 'field' => 'category', + 'value' => [$categoryA->id, $categoryB->name], + 'operator' => 'contains', + 'logic' => 'AND', + ], + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(2, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + $this->assertFalse($results->contains($assetC)); + + } +} \ No newline at end of file diff --git a/tests/Feature/AssetQuery/CombinedQueryTest.php b/tests/Feature/AssetQuery/CombinedQueryTest.php new file mode 100644 index 000000000000..8cb8a7470f14 --- /dev/null +++ b/tests/Feature/AssetQuery/CombinedQueryTest.php @@ -0,0 +1,1071 @@ +create(); + $modelB = AssetModel::factory()->create(); + + $locationA = Location::factory()->create(); + $locationB = Location::factory()->create(); + + // Assets + $modelALocationA = Asset::factory()->create(['model_id' => $modelA->id, 'location_id' => $locationA->id]); + $modelALocationB = Asset::factory()->create(['model_id' => $modelA->id, 'location_id' => $locationB->id]); + $modelBLocationA = Asset::factory()->create(['model_id' => $modelB->id, 'location_id' => $locationA->id]); + $modelBLocationB = Asset::factory()->create(['model_id' => $modelB->id, 'location_id' => $locationB->id]); + + $filter = [ + [ + 'field' => 'model', + 'value' => [$modelA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ], [ + 'field' => 'location', + 'value' => [$locationB->name], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($modelALocationB)); + $this->assertFalse($results->contains($modelALocationA)); + $this->assertFalse($results->contains($modelBLocationA)); + $this->assertFalse($results->contains($modelBLocationB)); + } + + public function testFilterAssetModelLocationArray() + { + $modelA = AssetModel::factory()->create(); + $modelB = AssetModel::factory()->create(); + + $locationA = Location::factory()->create(); + $locationB = Location::factory()->create(); + $locationC = Location::factory()->create(); + + // Assets + $modelALocationA = Asset::factory()->create(['model_id' => $modelA->id, 'location_id' => $locationA->id]); + $modelALocationB = Asset::factory()->create(['model_id' => $modelA->id, 'location_id' => $locationB->id]); + $modelALocationC = Asset::factory()->create(['model_id' => $modelA->id, 'location_id' => $locationC->id]); + $modelBLocationA = Asset::factory()->create(['model_id' => $modelB->id, 'location_id' => $locationA->id]); + $modelBLocationB = Asset::factory()->create(['model_id' => $modelB->id, 'location_id' => $locationB->id]); + $modelBLocationC = Asset::factory()->create(['model_id' => $modelA->id, 'location_id' => $locationC->id]); + + $filter = [ + [ + 'field' => 'model', + 'value' => [$modelB->name], + 'operator' => 'contains', + 'logic' => 'AND', + ],[ + 'field' => 'location', + 'value' => [$locationB->name,$locationA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(2, $results); + $this->assertTrue($results->contains($modelBLocationA)); + $this->assertTrue($results->contains($modelBLocationB)); + $this->assertFalse($results->contains($modelALocationA)); + $this->assertFalse($results->contains($modelALocationB)); + $this->assertFalse($results->contains($modelALocationC)); + $this->assertFalse($results->contains($modelBLocationC)); + + } + + public function testFilterAssetANDModelStatus() + { + $modelA = AssetModel::factory()->create(); + $modelB = AssetModel::factory()->create(); + + $statusA = Statuslabel::factory()->create(); + $statusB = Statuslabel::factory()->create(); + + $modelAStatusA = Asset::factory()->create(['model_id' => $modelA->id, 'status_id' => $statusA->id]); + $modelAStatusB = Asset::factory()->create(['model_id' => $modelA->id, 'status_id' => $statusB->id]); + $modelAStatusA = Asset::factory()->create(['model_id' => $modelB->id, 'status_id' => $statusA->id]); + $modelAStatusA = Asset::factory()->create(['model_id' => $modelB->id, 'status_id' => $statusB->id]); + + $filter = [ + [ + 'field' => 'model', + 'value' => [$modelA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ], [ + 'field' => 'status_label', + 'value' => [$statusB->name], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($modelAStatusB)); + $this->assertFalse($results->contains($modelAStatusA)); + $this->assertFalse($results->contains($modelAStatusA)); + $this->assertFalse($results->contains($modelAStatusA)); + + } + + public function testFilterAssetModelStatusArray() + { + $modelA = AssetModel::factory()->create(); + $modelB = AssetModel::factory()->create(); + + $statusA = Statuslabel::factory()->create(); + $statusB = Statuslabel::factory()->create(); + $statusC = Statuslabel::factory()->create(); + + $modelAStatusA = Asset::factory()->create(['model_id' => $modelA->id, 'status_id' => $statusA->id]); + $modelAStatusB = Asset::factory()->create(['model_id' => $modelA->id, 'status_id' => $statusB->id]); + $modelAStatusC = Asset::factory()->create(['model_id' => $modelA->id, 'status_id' => $statusC->id]); + $modelBStatusA = Asset::factory()->create(['model_id' => $modelB->id, 'status_id' => $statusA->id]); + + $filter = [ + ["field"=>"model","value"=>[$modelA->name],"operator"=>"contains","logic"=>"AND"], + ["field"=>"status_label","value"=>[$statusA->name, $statusB->name],"operator"=>"contains","logic"=>"AND"], + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + Log::error($results); + + $this->assertCount(2, $results); + $this->assertTrue($results->contains($modelAStatusA)); + $this->assertTrue($results->contains($modelAStatusB)); + $this->assertFalse($results->contains($modelAStatusC)); + $this->assertFalse($results->contains($modelBStatusA)); + } + + + public function testFilterAssetModelManufacturer() + { + $manufacturerA = Manufacturer::factory()->create(); + $manufacturerB = Manufacturer::factory()->create(); + + $modelAManufacturerA = AssetModel::factory()->create(['manufacturer_id' => $manufacturerA->id]); + $modelCManufacturerA = AssetModel::factory()->create(['manufacturer_id' => $manufacturerA->id]); + $modelCManufacturerB = AssetModel::factory()->create(['manufacturer_id' => $manufacturerB->id]); + $modelDManufacturerB = AssetModel::factory()->create(['manufacturer_id' => $manufacturerB->id]); + + $assetModelAManufacturerA = Asset::factory()->create(['model_id' => $modelAManufacturerA->id]); + $assetModelBManufacturerA = Asset::factory()->create(['model_id' => $modelCManufacturerA->id]); + $assetModelCManufacturerB = Asset::factory()->create(['model_id' => $modelCManufacturerB->id]); + $assetModelDManufacturerB = Asset::factory()->create(['model_id' => $modelDManufacturerB->id]); + + $filter = [ + ["field"=>"model", "value"=>[$modelAManufacturerA->name], "operator"=>"contains","logic"=>"AND"], + ["field"=>"manufacturer", "value"=>[$manufacturerA->name], "operator"=>"contains","logic"=>"AND"], + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetModelAManufacturerA)); + $this->assertFalse($results->contains($assetModelBManufacturerA)); + $this->assertFalse($results->contains($assetModelCManufacturerB)); + $this->assertFalse($results->contains($assetModelDManufacturerB)); + } + + + public function testFilterAssetModelManufacturerArray() + { + $manufacturerA = Manufacturer::factory()->create(); + $manufacturerB = Manufacturer::factory()->create(); + + $modelAManufacturerA = AssetModel::factory()->create(['manufacturer_id' => $manufacturerA->id]); + $modelBManufacturerA = AssetModel::factory()->create(['manufacturer_id' => $manufacturerA->id]); + $modelCManufacturerB = AssetModel::factory()->create(['manufacturer_id' => $manufacturerB->id]); + $modelDManufacturerB = AssetModel::factory()->create(['manufacturer_id' => $manufacturerB->id]); + + $assetModelAManufacturerA = Asset::factory()->create(['model_id' => $modelAManufacturerA->id]); + $assetModelBManufacturerA = Asset::factory()->create(['model_id' => $modelBManufacturerA->id]); + $assetModelCManufacturerB = Asset::factory()->create(['model_id' => $modelCManufacturerB->id]); + $assetModelDManufacturerB = Asset::factory()->create(['model_id' => $modelDManufacturerB->id]); + + $filter = [ + [ + "field"=>"model", + "value"=>[$modelAManufacturerA->name, $modelCManufacturerB->name], + "operator"=>"contains", + "logic"=>"AND" + ],[ + "field"=>"manufacturer", + "value"=>[$manufacturerA->name, $manufacturerB->name], + "operator"=>"contains", + "logic"=>"AND" + ], + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(2, $results); + $this->assertTrue($results->contains($assetModelAManufacturerA)); + $this->assertTrue($results->contains($assetModelCManufacturerB)); + $this->assertFalse($results->contains($assetModelBManufacturerA)); + $this->assertFalse($results->contains($assetModelDManufacturerB)); + } + + + public function testFilterAssetLocationStatus() + { + $locationA = Location::factory()->create(); + $locationB = Location::factory()->create(); + + $statusA = Statuslabel::factory()->create(); + $statusB = Statuslabel::factory()->create(); + + $assetA = Asset::factory()->create(['location_id' => $locationA->id, 'status_id' => $statusA->id]); + $assetB = Asset::factory()->create(['location_id' => $locationA->id, 'status_id' => $statusB->id]); + $assetC = Asset::factory()->create(['location_id' => $locationB->id, 'status_id' => $statusA->id]); + $assetD = Asset::factory()->create(['location_id' => $locationB->id, 'status_id' => $statusB->id]); + + $filter = [ + [ + "field"=>"location", + "value"=>[$locationA->name], + "operator"=>"contains", + "logic"=>"AND" + ],[ + "field"=>"status_label", + "value"=>[$statusB->name], + "operator"=>"contains", + "logic"=>"AND" + ], + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetB)); + $this->assertFalse($results->contains($assetA)); + $this->assertFalse($results->contains($assetC)); + $this->assertFalse($results->contains($assetD)); + } + + public function testFilterAssetLocationArrayStatus() + { + $locationA = Location::factory()->create(); + $locationB = Location::factory()->create(); + $locationC = Location::factory()->create(); + + $statusA = Statuslabel::factory()->create(); + + $assetA = Asset::factory()->create(['location_id' => $locationA->id, 'status_id' => $statusA->id]); + $assetB = Asset::factory()->create(['location_id' => $locationB->id, 'status_id' => $statusA->id]); + $assetC = Asset::factory()->create(['location_id' => $locationC->id, 'status_id' => $statusA->id]); + $assetD = Asset::factory()->create(['location_id' => $locationB->id, 'status_id' => $statusA->id]); + + + $filter = [ + [ + "field"=>"location", + "value"=>[$locationA->name, $locationB->name], + "operator"=>"contains", + "logic"=>"AND" + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(3, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + $this->assertTrue($results->contains($assetD)); + $this->assertFalse($results->contains($assetC)); + } + + public function testFilterAssetLocationManufacturer() + { + $locationA = Location::factory()->create(); + $locationB = Location::factory()->create(); + + $manufacturerA = Manufacturer::factory()->create(); + $manufacturerB = Manufacturer::factory()->create(); + + $modelA = AssetModel::factory()->create(['manufacturer_id' => $manufacturerA->id]); + $modelB = AssetModel::factory()->create(['manufacturer_id' => $manufacturerB->id]); + + $assetA = Asset::factory()->create(['model_id' => $modelA->id, 'location_id' => $locationA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id, 'location_id' => $locationA->id]); + $assetC = Asset::factory()->create(['model_id' => $modelA->id, 'location_id' => $locationB->id]); + $assetD = Asset::factory()->create(['model_id' => $modelB->id, 'location_id' => $locationB->id]); + + $filter = [ + [ + 'field' => 'location', + 'value' => [$locationA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ], [ + 'field' => 'manufacturer', + 'value' => [$manufacturerA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + $this->assertFalse($results->contains($assetC)); + $this->assertFalse($results->contains($assetD)); + } + + public function testFilterAssetLocationArrayManufacturerArray() + { + $locationA = Location::factory()->create(); + $locationB = Location::factory()->create(); + + $manufacturerA = Manufacturer::factory()->create(); + $manufacturerB = Manufacturer::factory()->create(); + + $modelA = AssetModel::factory()->create(['manufacturer_id' => $manufacturerA->id]); + $modelB = AssetModel::factory()->create(['manufacturer_id' => $manufacturerB->id]); + $modelC = AssetModel::factory()->create(['manufacturer_id' => $manufacturerA->id]); + $modelD = AssetModel::factory()->create(['manufacturer_id' => $manufacturerB->id]); + + $assetA = Asset::factory()->create(['model_id' => $modelA->id, 'location_id' => $locationA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id, 'location_id' => $locationA->id]); + $assetC = Asset::factory()->create(['model_id' => $modelC->id, 'location_id' => $locationB->id]); + $assetD = Asset::factory()->create(['model_id' => $modelD->id, 'location_id' => $locationB->id]); + + $filter = [ + [ + 'field' => 'location', + 'value' => [$locationA->name, $locationB->name], + 'operator' => 'contains', + 'logic' => 'AND', + ], [ + 'field' => 'manufacturer', + 'value' => [$manufacturerA->name, $manufacturerB->name], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(4, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + $this->assertTrue($results->contains($assetC)); + $this->assertTrue($results->contains($assetD)); + } + + public function testFilterAssetStatusManufacturer() + { + $statusA = Statuslabel::factory()->create(); + $statusB = Statuslabel::factory()->create(); + + $manufacturerA = Manufacturer::factory()->create(); + $manufacturerB = Manufacturer::factory()->create(); + + $modelA = AssetModel::factory()->create(['manufacturer_id' => $manufacturerA->id]); + $modelB = AssetModel::factory()->create(['manufacturer_id' => $manufacturerB->id]); + + $assetA = Asset::factory()->create(['model_id' => $modelA->id, 'status_id' => $statusA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id, 'status_id' => $statusA->id]); + $assetC = Asset::factory()->create(['model_id' => $modelA->id, 'status_id' => $statusB->id]); + $assetD = Asset::factory()->create(['model_id' => $modelB->id, 'status_id' => $statusB->id]); + + $filter = [ + [ + 'field' => 'status_label', + 'value' => [$statusA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ], [ + 'field' => 'manufacturer', + 'value' => [$manufacturerA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + $this->assertFalse($results->contains($assetC)); + $this->assertFalse($results->contains($assetD)); + } + + public function testFilterAssetStatusArrayManufacturerArray() + { + $statusA = Statuslabel::factory()->create(); + $statusB = Statuslabel::factory()->create(); + + $manufacturerA = Manufacturer::factory()->create(); + $manufacturerB = Manufacturer::factory()->create(); + + $modelA = AssetModel::factory()->create(['manufacturer_id' => $manufacturerA->id]); + $modelB = AssetModel::factory()->create(['manufacturer_id' => $manufacturerB->id]); + $modelC = AssetModel::factory()->create(['manufacturer_id' => $manufacturerA->id]); + $modelD = AssetModel::factory()->create(['manufacturer_id' => $manufacturerB->id]); + + $assetA = Asset::factory()->create(['model_id' => $modelA->id, 'status_id' => $statusA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id, 'status_id' => $statusA->id]); + $assetC = Asset::factory()->create(['model_id' => $modelC->id, 'status_id' => $statusB->id]); + $assetD = Asset::factory()->create(['model_id' => $modelD->id, 'status_id' => $statusB->id]); + + $filter = [ + [ + 'field' => 'status_label', + 'value' => [$statusA->name, $statusB->name], + 'operator' => 'contains', + 'logic' => 'AND', + ], [ + 'field' => 'manufacturer', + 'value' => [$manufacturerA->name, $manufacturerB->name], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(4, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + $this->assertTrue($results->contains($assetC)); + $this->assertTrue($results->contains($assetD)); + } + + public function testFilterAssetModelLocationStatus() + { + $modelA = AssetModel::factory()->create(); + $modelB = AssetModel::factory()->create(); + $locationA = Location::factory()->create(); + $locationB = Location::factory()->create(); + $statusA = Statuslabel::factory()->create(); + $statusB = Statuslabel::factory()->create(); + + $assetA = Asset::factory()->create(['model_id' => $modelA->id, 'location_id' => $locationA->id, 'status_id' => $statusA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelA->id, 'location_id' => $locationB->id, 'status_id' => $statusA->id]); + $assetC = Asset::factory()->create(['model_id' => $modelB->id, 'location_id' => $locationA->id, 'status_id' => $statusB->id]); + $assetD = Asset::factory()->create(['model_id' => $modelB->id, 'location_id' => $locationB->id, 'status_id' => $statusB->id]); + + $filter = [ + [ + 'field' => 'model', + 'value' => [$modelA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ], [ + 'field' => 'location', + 'value' => [$locationB->name], + 'operator' => 'contains', + 'logic' => 'AND', + ], [ + 'field' => 'status_label', + 'value' => [$statusA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetB)); + $this->assertFalse($results->contains($assetA)); + $this->assertFalse($results->contains($assetC)); + $this->assertFalse($results->contains($assetD)); + } + + public function testFilterAssetModelLocationArrayStatusArray() + { + $modelA = AssetModel::factory()->create(); + $modelB = AssetModel::factory()->create(); + $locationA = Location::factory()->create(); + $locationB = Location::factory()->create(); + $statusA = Statuslabel::factory()->create(); + $statusB = Statuslabel::factory()->create(); + + $assetA = Asset::factory()->create(['model_id' => $modelA->id, 'location_id' => $locationA->id, 'status_id' => $statusA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelA->id, 'location_id' => $locationB->id, 'status_id' => $statusB->id]); + $assetC = Asset::factory()->create(['model_id' => $modelB->id, 'location_id' => $locationA->id, 'status_id' => $statusA->id]); + $assetD = Asset::factory()->create(['model_id' => $modelB->id, 'location_id' => $locationB->id, 'status_id' => $statusB->id]); + + $filter = [ + [ + 'field' => 'model', + 'value' => [$modelA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ], [ + 'field' => 'location', + 'value' => [$locationA->name, $locationB->name], + 'operator' => 'contains', + 'logic' => 'AND', + ], [ + 'field' => 'status_label', + 'value' => [$statusA->name, $statusB->name], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(2, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + $this->assertFalse($results->contains($assetC)); + $this->assertFalse($results->contains($assetD)); + } + + public function testFilterAssetModelLocationManufacturer() + { + $modelA = AssetModel::factory()->create(); + $modelB = AssetModel::factory()->create(); + $locationA = Location::factory()->create(); + $locationB = Location::factory()->create(); + $manufacturerA = Manufacturer::factory()->create(); + $manufacturerB = Manufacturer::factory()->create(); + + $modelA->manufacturer_id = $manufacturerA->id; + $modelA->save(); + $modelB->manufacturer_id = $manufacturerB->id; + $modelB->save(); + + $assetA = Asset::factory()->create(['model_id' => $modelA->id, 'location_id' => $locationA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelA->id, 'location_id' => $locationB->id]); + $assetC = Asset::factory()->create(['model_id' => $modelB->id, 'location_id' => $locationA->id]); + $assetD = Asset::factory()->create(['model_id' => $modelB->id, 'location_id' => $locationB->id]); + + $filter = [ + [ + 'field' => 'model', + 'value' => [$modelA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ], [ + 'field' => 'location', + 'value' => [$locationA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ], [ + 'field' => 'manufacturer', + 'value' => [$manufacturerA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + $this->assertFalse($results->contains($assetC)); + $this->assertFalse($results->contains($assetD)); + } + public function testFilterAssetModelLocationArrayManufacturerArray() + { + $modelA = AssetModel::factory()->create(); + $modelB = AssetModel::factory()->create(); + $locationA = Location::factory()->create(); + $locationB = Location::factory()->create(); + $manufacturerA = Manufacturer::factory()->create(); + $manufacturerB = Manufacturer::factory()->create(); + + $modelA->manufacturer_id = $manufacturerA->id; + $modelA->save(); + $modelB->manufacturer_id = $manufacturerB->id; + $modelB->save(); + + $assetA = Asset::factory()->create(['model_id' => $modelA->id, 'location_id' => $locationA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelA->id, 'location_id' => $locationB->id]); + $assetC = Asset::factory()->create(['model_id' => $modelB->id, 'location_id' => $locationA->id]); + $assetD = Asset::factory()->create(['model_id' => $modelB->id, 'location_id' => $locationB->id]); + + $filter = [ + [ + 'field' => 'model', + 'value' => [$modelA->name, $modelB->name], + 'operator' => 'contains', + 'logic' => 'AND', + ], [ + 'field' => 'location', + 'value' => [$locationA->name, $locationB->name], + 'operator' => 'contains', + 'logic' => 'AND', + ], [ + 'field' => 'manufacturer', + 'value' => [$manufacturerA->name, $manufacturerB->name], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(4, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + $this->assertTrue($results->contains($assetC)); + $this->assertTrue($results->contains($assetD)); + } + + public function testFilterAssetModelStatusManufacturer() + { + $modelA = AssetModel::factory()->create(); + $modelB = AssetModel::factory()->create(); + $statusA = Statuslabel::factory()->create(); + $statusB = Statuslabel::factory()->create(); + $manufacturerA = Manufacturer::factory()->create(); + $manufacturerB = Manufacturer::factory()->create(); + + $modelA->manufacturer_id = $manufacturerA->id; + $modelA->save(); + $modelB->manufacturer_id = $manufacturerB->id; + $modelB->save(); + + $assetA = Asset::factory()->create(['model_id' => $modelA->id, 'status_id' => $statusA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelA->id, 'status_id' => $statusB->id]); + $assetC = Asset::factory()->create(['model_id' => $modelB->id, 'status_id' => $statusA->id]); + $assetD = Asset::factory()->create(['model_id' => $modelB->id, 'status_id' => $statusB->id]); + + $filter = [ + [ + 'field' => 'model', + 'value' => [$modelA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ], [ + 'field' => 'status_label', + 'value' => [$statusA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ], [ + 'field' => 'manufacturer', + 'value' => [$manufacturerA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + $this->assertFalse($results->contains($assetC)); + $this->assertFalse($results->contains($assetD)); + } + + public function testFilterAssetModelStatusArrayManufacturerArray() + { + $modelA = AssetModel::factory()->create(); + $modelB = AssetModel::factory()->create(); + $statusA = Statuslabel::factory()->create(); + $statusB = Statuslabel::factory()->create(); + $manufacturerA = Manufacturer::factory()->create(); + $manufacturerB = Manufacturer::factory()->create(); + + $modelA->manufacturer_id = $manufacturerA->id; + $modelA->save(); + $modelB->manufacturer_id = $manufacturerB->id; + $modelB->save(); + + $assetA = Asset::factory()->create(['model_id' => $modelA->id, 'status_id' => $statusA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelA->id, 'status_id' => $statusB->id]); + $assetC = Asset::factory()->create(['model_id' => $modelB->id, 'status_id' => $statusA->id]); + $assetD = Asset::factory()->create(['model_id' => $modelB->id, 'status_id' => $statusB->id]); + + $filter = [ + [ + 'field' => 'model', + 'value' => [$modelA->name, $modelB->name], + 'operator' => 'contains', + 'logic' => 'AND', + ], [ + 'field' => 'manufacturer', + 'value' => [$manufacturerA->name, $manufacturerB->name], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(4, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + $this->assertTrue($results->contains($assetC)); + $this->assertTrue($results->contains($assetD)); + } + + // Edge cases: + + public function testFilterAssetNoFiltersReturnsAll() + { + $assetA = Asset::factory()->create(); + $assetB = Asset::factory()->create(); + $assetC = Asset::factory()->create(); + + // No filters applied + $filter = []; + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(3, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + $this->assertTrue($results->contains($assetC)); + } + + public function testFilterAssetAllFiltersAsStrings() + { + $model = AssetModel::factory()->create(); + $location = Location::factory()->create(); + $manufacturer = Manufacturer::factory()->create(); + $status = Statuslabel::factory()->create(); + + $model->manufacturer_id = $manufacturer->id; + $model->save(); + + $assetA = Asset::factory()->create([ + 'model_id' => $model->id, + 'location_id' => $location->id, + 'status_id' => $status->id + ]); + $assetB = Asset::factory()->create(); // Should not match + + $filter = [ + [ + 'field' => 'model', + 'value' => [$model->name], + 'operator' => 'contains', + 'logic' => 'AND', + ], [ + 'field' => 'location', + 'value' => [$location->name], + 'operator' => 'contains', + 'logic' => 'AND', + ], [ + 'field' => 'manufacturer', + 'value' => [$manufacturer->name], + 'operator' => 'contains', + 'logic' => 'AND', + ], [ + 'field' => 'status_label', + 'value' => [$status->name], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + } + + public function testFilterAssetAllFiltersAsArrays() + { + $modelA = AssetModel::factory()->create(); + $modelB = AssetModel::factory()->create(); + + $locationA = Location::factory()->create(); + $locationB = Location::factory()->create(); + + $manufacturerA = Manufacturer::factory()->create(); + $manufacturerB = Manufacturer::factory()->create(); + + $statusA = Statuslabel::factory()->create(); + $statusB = Statuslabel::factory()->create(); + + $modelA->manufacturer_id = $manufacturerA->id; + $modelA->save(); + $modelB->manufacturer_id = $manufacturerB->id; + $modelB->save(); + + $assetA = Asset::factory()->create([ + 'model_id' => $modelA->id, + 'location_id' => $locationA->id, + 'status_id' => $statusA->id + ]); + $assetB = Asset::factory()->create([ + 'model_id' => $modelB->id, + 'location_id' => $locationB->id, + 'status_id' => $statusB->id + ]); + $assetC = Asset::factory()->create(); // Should not match + + $filter = [ + ["field"=>"model", "value"=>[$modelA->name, $modelB->name],"operator"=>"contains","logic"=>"AND"], + ["field"=>"location", "value"=>[$locationA->name, $locationB->name],"operator"=>"contains","logic"=>"AND"], + ["field"=>"manufacturer", "value"=>[$manufacturerA->name, $manufacturerB->name],"operator"=>"contains","logic"=>"AND"], + ["field"=>"status_label", "value"=>[$statusA->name, $statusB->name],"operator"=>"contains","logic"=>"AND"], + + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(2, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + $this->assertFalse($results->contains($assetC)); + } + + public function testFilterWithEmptyArrayReturnsNone() + { + Asset::factory()->count(3)->create(); + + $filter = [ + [ + 'field' => 'model', + 'value' => [], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(3, $results); + } + + public function testFilterWithNonexistentValueReturnsNone() + { + Asset::factory()->count(3)->create(); + + $filter = [ + [ + 'field' => 'status_label', + 'value' => ['NonexistentStatus'], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(0, $results); + } + + public function testFilterWithMixedValuesReturnsMatchingOnly() + { + $modelA = AssetModel::factory()->create(); + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + + $filter = [ + [ + 'field' => 'model', + 'value' => [$modelA->name, 'NonexistentStatus'], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + } + + public function testFilterWithDuplicateValuesReturnsUniqueResults() + { + $modelA = AssetModel::factory()->create(); + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + + $filter = [ + [ + 'field' => 'model', + 'value' => [$modelA->name, $modelA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + } + + public function testFilterWithNullValueReturnsNone() + { + $numberOfAssets = 5; + Asset::factory()->count($numberOfAssets)->create(); + + $filter = [ + [ + "field"=>"location", + "value"=>null, + "operator"=>"contains", + "logic"=>"AND"], + ]; + + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount($numberOfAssets, $results); + } + + public function testConflictingFiltersReturnNone() + { + $modelA = AssetModel::factory()->create(); + $manufacturerB = Manufacturer::factory()->create(); + $modelA->manufacturer_id = $manufacturerB->id + 1; // Not matching + $modelA->save(); + + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + + $filter = [ + [ + 'field' => 'model', + 'value' => [$modelA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ],[ + 'field' => 'manufacturer', + 'value' => ['NonexistentManufaturer'], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(0, $results); + } + + public function testLargeArrayOfModelsReturnsAllMatching() + { + $models = AssetModel::factory()->count(50)->create(); + foreach ($models as $model) { + Asset::factory()->create(['model_id' => $model->id]); + } + + $filter = [ + [ + 'field' => 'model', + 'value' => [$models->pluck('name')->toArray()], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(50, $results); + } + + public function testCombinationOfArrayAndStringFilters() + { + $modelA = AssetModel::factory()->create(); + $modelB = AssetModel::factory()->create(); + $locationA = Location::factory()->create(); + $assetA = Asset::factory()->create(['model_id' => $modelA->id, 'location_id' => $locationA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id, 'location_id' => $locationA->id]); + + $filter = [ + [ + 'field' => 'model', + 'value' => [$modelA->name, $modelB->name], + 'operator' => 'contains', + 'logic' => 'AND', + ], [ + 'field' => 'location', + 'value' => [$locationA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(2, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + } + + public function testOverlappingFiltersReturnsAllMatches() + { + $manufacturerA = Manufacturer::factory()->create(); + $modelA = AssetModel::factory()->create(['manufacturer_id' => $manufacturerA->id]); + $modelB = AssetModel::factory()->create(['manufacturer_id' => $manufacturerA->id]); + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + + $filter = [ + [ + 'field' => 'manufacturer', + 'value' => [$manufacturerA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ], [ + 'field' => 'model', + 'value' => [$modelA->name, $modelB->name], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(2, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + } + + public function testFilterWithSpecialCharacters() + { + $model = AssetModel::factory()->create(['name' => 'Mödel#1']); + $asset = Asset::factory()->create(['model_id' => $model->id]); + + $filter = [ + [ + 'field' => 'model', + 'value' => ['Mödel#1'], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + $this->assertCount(1, $results); + $this->assertTrue($results->contains($asset)); + } + + public function testFilterAssetsWithMissingForeignKey() + { + $locationA = Location::factory()->create(); + $assetWithLocation = Asset::factory()->create(['location_id' => $locationA->id]); + $assetWithoutLocation = Asset::factory()->create(['location_id' => null]); + + $filter = [ + [ + 'field' => 'location', + 'value' => [$locationA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetWithLocation)); + $this->assertFalse($results->contains($assetWithoutLocation)); + } +} \ No newline at end of file diff --git a/tests/Feature/AssetQuery/CompanyQueryTest.php b/tests/Feature/AssetQuery/CompanyQueryTest.php new file mode 100644 index 000000000000..10a6b65642d5 --- /dev/null +++ b/tests/Feature/AssetQuery/CompanyQueryTest.php @@ -0,0 +1,277 @@ +create(); + $companyB = Company::factory()->create(); + + // Assets with direct company_id + $assetA = Asset::factory()->create([ + 'company_id' => $companyA->id, + ]); + $assetB = Asset::factory()->create([ + 'company_id' => $companyB->id, + ]); + + + $filter = [ + [ + 'field' => 'company', + 'value' => [''], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(2, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + } + + + public function testFilterAssetCompanyStringComplete() + { + $companyA = Company::factory()->create(); + $companyB = Company::factory()->create(); + + // Assets with direct company_id + $assetA = Asset::factory()->create([ + 'company_id' => $companyA->id, + ]); + $assetB = Asset::factory()->create([ + 'company_id' => $companyB->id, + ]); + + $filter = [ + [ + 'field' => 'company', + 'value' => [$companyA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + } + + public function testFilterAssetCompanyStringPartial() + { + $companyA = Company::factory()->create(); + $companyB = Company::factory()->create(); + + // Assets with direct company_id + $assetA = Asset::factory()->create([ + 'company_id' => $companyA->id, + ]); + $assetB = Asset::factory()->create([ + 'company_id' => $companyB->id, + ]); + + $queryString = CompanyQueryTest::getExtendedPrefix($companyA->name, $companyB->name); + + $filter = [ + [ + 'field' => 'company', + 'value' => [$queryString], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + } + + public function testFilterAssetCompanyArraySingle() + { + + $companyA = Company::factory()->create(); + $companyB = Company::factory()->create(); + + + // Assets with direct company_id + $assetA = Asset::factory()->create([ + 'company_id' => $companyA->id, + ]); + $assetB = Asset::factory()->create([ + 'company_id' => $companyB->id, + ]); + + $filter = [ + [ + 'field' => 'company', + 'value' => [$companyA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + + } + + public function testFilterAssetCompanyArrayMultiple() + { + $companyA = Company::factory()->create(); + $companyB = Company::factory()->create(); + $companyC = Company::factory()->create(); + $companyD = Company::factory()->create(); + $companyE = Company::factory()->create(); + + // Assets with direct company_id + $assetA = Asset::factory()->create([ + 'company_id' => $companyA->id, + ]); + $assetB = Asset::factory()->create([ + 'company_id' => $companyB->id, + ]); + $assetC = Asset::factory()->create([ + 'company_id' => $companyC->id, + ]); + $assetD = Asset::factory()->create([ + 'company_id' => $companyD->id, + ]); + $assetE = Asset::factory()->create([ + 'company_id' => $companyE->id, + ]); + + + $filter = [ + [ + 'field' => 'company', + 'value' => [$companyB->id, $companyE->id], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(2, $results); + $this->assertTrue($results->contains($assetB)); + $this->assertTrue($results->contains($assetE)); + $this->assertFalse($results->contains($assetA)); + $this->assertFalse($results->contains($assetC)); + $this->assertFalse($results->contains($assetD)); + + } + + // testFilterAssetAssignedToCategoryID + public function testFilterAssetCompanyId() + { + $companyA = Company::factory()->create(); + $companyB = Company::factory()->create(); + + // Assets with direct company_id + $assetA = Asset::factory()->create([ + 'company_id' => $companyA->id, + ]); + $assetB = Asset::factory()->create([ + 'company_id' => $companyB->id, + ]); + + $filter = [ + [ + 'field' => 'company', + 'value' => [$companyA->id,], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + } + + public function testFilterAssetCompanyIdArraySingle() + { + $companyA = Company::factory()->create(); + $companyB = Company::factory()->create(); + + // Assets with direct company_id + $assetA = Asset::factory()->create([ + 'company_id' => $companyA->id, + ]); + $assetB = Asset::factory()->create([ + 'company_id' => $companyB->id, + ]); + + $filter = [ + [ + 'field' => 'company', + 'value' => [$companyA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + } + + public function testFilterAssetCompanyIdAndNameArray() + { + $companyA = Company::factory()->create(); + $companyB = Company::factory()->create(); + $companyC = Company::factory()->create(); + + // Assets with direct company_id + $assetA = Asset::factory()->create([ + 'company_id' => $companyA->id, + ]); + $assetB = Asset::factory()->create([ + 'company_id' => $companyB->id, + ]); + $assetC = Asset::factory()->create([ + 'company_id' => $companyC->id, + ]); + + $filter = [ + [ + 'field' => 'company', + 'value' => [$companyA->id, $companyB->name], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(2, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + $this->assertFalse($results->contains($assetC)); + } +} \ No newline at end of file diff --git a/tests/Feature/AssetQuery/CustomFieldQueryTest.php b/tests/Feature/AssetQuery/CustomFieldQueryTest.php new file mode 100644 index 000000000000..4b70c4dad8cd --- /dev/null +++ b/tests/Feature/AssetQuery/CustomFieldQueryTest.php @@ -0,0 +1,166 @@ +string('custom_text')->nullable()->index(); + } + if (!Schema::hasColumn('assets', 'custom_flag')) { + $table->string('custom_flag')->nullable()->index(); + } + if (!Schema::hasColumn('assets', 'custom_code')) { + $table->string('custom_code')->nullable()->index(); + } + }); + } + + public function testFilterBySingleCustomFieldStringLike() + { + $aMatch = Asset::factory()->create(['custom_text' => 'Alpha Blue']); + $aNoMatch1 = Asset::factory()->create(['custom_text' => 'Gamma Green']); + $aNoMatch2 = Asset::factory()->create(['custom_text' => 'Delta Red']); + + $filter = [ + [ + 'field' => 'custom_text', + 'value' => ['Blu'], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($aMatch)); + $this->assertFalse($results->contains($aNoMatch1)); + $this->assertFalse($results->contains($aNoMatch2)); + } + + public function testFilterBooleanLikeCustomFieldArrayAndString() + { + $on = Asset::factory()->create(['custom_flag' => '1']); + $off = Asset::factory()->create(['custom_flag' => '0']); + + $filterOn = [ + [ + 'field' => 'custom_flag', + 'value' => ['1'], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $filterOff = [ + [ + 'field' => 'custom_flag', + 'value' => ['1'], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $resIn = Asset::query()->byFilter($filterOn)->get(); + $this->assertCount(1, $resIn); + $this->assertTrue($resIn->contains($on)); + $this->assertFalse($resIn->contains($off)); + + $resLike = Asset::query()->byFilter($filterOff)->get(); + $this->assertCount(1, $resLike); + $this->assertTrue($resLike->contains($on)); + $this->assertFalse($resLike->contains($off)); + } + + public function testFilterMultipleCustomFieldsCombined() + { + + $hit = Asset::factory()->create(['custom_text' => 'Report Q3', 'custom_code' => 'R-2025']); + $missText = Asset::factory()->create(['custom_text' => 'Notes Q3', 'custom_code' => 'R-2025']); + $missCode = Asset::factory()->create(['custom_text' => 'Report Q3', 'custom_code' => 'X-0001']); + + $filter = [ + [ + 'field' => 'custom_text', + 'value' => ['Report'], + 'operator' => 'contains', + 'logic' => 'AND', + ], + [ + 'field' => 'custom_code', + 'value' => ['R-2025'], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($hit)); + $this->assertFalse($results->contains($missText)); + $this->assertFalse($results->contains($missCode)); + } + + public function testFilterWithEmptyArrayLeavesResultsUnchanged() + { + $a = Asset::factory()->create(['custom_text' => 'A']); + $b = Asset::factory()->create(['custom_text' => 'B']); + + $filter = []; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(2, $results); + $this->assertTrue($results->contains($a)); + $this->assertTrue($results->contains($b)); + } + + public function testFilterWithNonexistentValueReturnsNone() + { + Asset::factory()->count(3)->create(['custom_text' => 'X']); + $filter = [ + [ + 'field' => 'custom_text', + 'value' => ['does-not-exist'], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(0, $results); + } + + public function testFilterWithSpecialCharactersInCustomField() + { + $match = Asset::factory()->create(['custom_text' => 'Mödel#1 (ß)']); + $nope = Asset::factory()->create(['custom_text' => 'Model 2']); + + $filter = [ + [ + 'field' => 'custom_text', + 'value' => ['Mödel#1'], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($match)); + $this->assertFalse($results->contains($nope)); + } +} diff --git a/tests/Feature/AssetQuery/DateQueryTest.php b/tests/Feature/AssetQuery/DateQueryTest.php new file mode 100644 index 000000000000..ab198d2c2c20 --- /dev/null +++ b/tests/Feature/AssetQuery/DateQueryTest.php @@ -0,0 +1,151 @@ +create(['purchase_date' => Carbon::now()->addDays(14)->toDateString()]); + $assetB = Asset::factory()->create(['purchase_date' => Carbon::now()->addWeeks(14)->toDateString()]); + $assetC = Asset::factory()->create(['purchase_date' => Carbon::now()->addMonths(14)->toDateString()]); + + $filter = [[ + 'field' => 'purchase_date', + 'value' => ['startDate' => Carbon::now()->addMonths(2)->toDateString()], + 'operator' => 'contains', + 'logic' => 'AND', + ]]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(2, $results); + $this->assertTrue($results->contains($assetB)); + $this->assertTrue($results->contains($assetC)); + $this->assertFalse($results->contains($assetA)); + } + + public function testPurchaseDateQueryEnd() + { + Carbon::setTestNow(Carbon::create(2015, 2, 1)); + + $assetA = Asset::factory()->create(['purchase_date' => Carbon::now()->addWeeks(5)->toDateString()]); + $assetB = Asset::factory()->create(['purchase_date' => Carbon::now()->addMonths(14)->toDateString()]); + $assetC = Asset::factory()->create(['purchase_date' => Carbon::now()->addWeeks(10)->toDateString()]); + + $filter = [[ + 'field' => 'purchase_date', + 'value' => ['endDate' => Carbon::now()->addWeeks(7)->toDateString()], + 'operator' => 'contains', + 'logic' => 'AND', + ]]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + $this->assertFalse($results->contains($assetC)); + } + + public function testPurchaseDateQueryRange() + { + Carbon::setTestNow(Carbon::create(2025, 3, 4)); + + $assetA = Asset::factory()->create(['purchase_date' => Carbon::now()->addWeeks(50)->toDateString()]); + $assetB = Asset::factory()->create(['purchase_date' => Carbon::now()->addWeeks(75)->toDateString()]); + $assetC = Asset::factory()->create(['purchase_date' => Carbon::now()->addWeeks(100)->toDateString()]); + $assetD = Asset::factory()->create(['purchase_date' => Carbon::now()->addWeeks(125)->toDateString()]); + $assetE = Asset::factory()->create(['purchase_date' => Carbon::now()->addWeeks(150)->toDateString()]); + + $filter = [[ + 'field' => 'purchase_date', + 'value' => [ + 'startDate' => Carbon::now()->addWeeks(70)->toDateString(), + 'endDate' => Carbon::now()->addWeeks(130)->toDateString(), + ], + 'operator' => 'contains', + 'logic' => 'AND', + ]]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(3, $results); + $this->assertTrue($results->contains($assetB)); + $this->assertTrue($results->contains($assetC)); + $this->assertTrue($results->contains($assetD)); + $this->assertFalse($results->contains($assetA)); + $this->assertFalse($results->contains($assetE)); + } + + public function testEolDateQueryEnd() + { + Carbon::setTestNow('2020-01-01'); + + $prefix = 'EOLENDQ-' . \Illuminate\Support\Str::random(6); + + $modelA = AssetModel::factory()->create(['eol' => 12]); + $modelB = AssetModel::factory()->create(['eol' => 24]); + $modelC = AssetModel::factory()->create(['eol' => 36]); + + $purchase = '2020-01-01'; + $eolA = '2021-01-01'; + $eolB = '2022-01-01'; + $eolC = '2023-01-01'; + + + $assetA = Asset::factory()->create([ + 'model_id' => $modelA->id, + 'purchase_date' => $purchase, + 'asset_tag' => $prefix.'-A', + ]); + $assetB = Asset::factory()->create([ + 'model_id' => $modelB->id, + 'purchase_date' => $purchase, + 'asset_tag' => $prefix.'-B', + ]); + $assetC = Asset::factory()->create([ + 'model_id' => $modelC->id, + 'purchase_date' => $purchase, + 'asset_tag' => $prefix.'-C', + ]); + + // needed because on creation there is a randomizer in the factory + + $assetA->update(['asset_eol_date' => $eolA]); + $assetB->update(['asset_eol_date' => $eolB]); + $assetC->update(['asset_eol_date' => $eolC]); + + $filter = [ + [ + 'field' => 'asset_eol_date', + 'value' => ['endDate' => '2021-06-30'], + 'operator' => 'contains', + 'logic' => 'AND', + ], + [ + 'field' => 'asset_tag', + 'value' => $prefix, + 'operator' => 'contains', + 'logic' => 'AND', + ], + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + $this->assertFalse($results->contains($assetC)); + } +} \ No newline at end of file diff --git a/tests/Feature/AssetQuery/LegacyFilterTest.php b/tests/Feature/AssetQuery/LegacyFilterTest.php new file mode 100644 index 000000000000..49a92b8861c1 --- /dev/null +++ b/tests/Feature/AssetQuery/LegacyFilterTest.php @@ -0,0 +1,88 @@ +create(); + $companyB = Company::factory()->create(); + + $assetA = Asset::factory()->create(['company_id' => $companyA->id]); + $assetB = Asset::factory()->create(['company_id' => $companyB->id]); + + $filter = [ + 'company_id' => $companyA->id + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + } + + public function testFilterAssetsByAssetTag() + { + $assetA = Asset::factory()->create(['asset_tag' => 'A1']); + $assetB = Asset::factory()->create(['asset_tag' => 'B1']); + + $filter = [ + 'asset_tag' => 'A1' + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + } + + public function testFilterAssetsByCompanyAndAssetTag() + { + $companyA = Company::factory()->create(); + $companyB = Company::factory()->create(); + + $assetA = Asset::factory()->create([ + 'company_id' => $companyA->id, + 'asset_tag' => 'X1' + ]); + + $assetB = Asset::factory()->create([ + 'company_id' => $companyA->id, + 'asset_tag' => 'Y1' + ]); + + $assetC = Asset::factory()->create([ + 'company_id' => $companyB->id, + 'asset_tag' => 'X1' + ]); + + $filter = [ + 'company_id' => $companyA->id, + 'asset_tag' => 'X1' + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + } + + public function testReturnsAllAssetsWhenFilterIsEmpty() + { + $assetA = Asset::factory()->create(); + $assetB = Asset::factory()->create(); + + $filter = []; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(2, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + } +} diff --git a/tests/Feature/AssetQuery/LocationQueryTest.php b/tests/Feature/AssetQuery/LocationQueryTest.php new file mode 100644 index 000000000000..b673384c186e --- /dev/null +++ b/tests/Feature/AssetQuery/LocationQueryTest.php @@ -0,0 +1,254 @@ +create(); + $locationB = Location::factory()->create(); + + $assetA = Asset::factory()->create(['location_id' => $locationA->id]); + $assetB = Asset::factory()->create(['location_id' => $locationB->id]); + + $filter = [ + [ + 'field' => 'location', + 'value' => [''], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + // Then: Should include only assetA and assetB + $this->assertCount(2, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + + } + + + public function testFilterAssetLocationStringComplete() + { + + // Given: Location and assets + $locationA = Location::factory()->create(); + $locationB = Location::factory()->create(); + + $assetA = Asset::factory()->create(['location_id' => $locationA->id]); + $assetB = Asset::factory()->create(['location_id' => $locationB->id]); + + $filter = [ + [ + 'field' => 'location', + 'value' => [$locationA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + + // Then: Should include only assetA and assetB + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + + } + + public function testFilterAssetLocationStringPartial() + { + + // Given: Locations and assets + $locationA = Location::factory()->create(); + $locationB = Location::factory()->create(); + + $assetA = Asset::factory()->create(['location_id' => $locationA->id]); + $assetB = Asset::factory()->create(['location_id' => $locationB->id]); + + $queryString = LocationQueryTest::getExtendedPrefix($locationA->name, $locationB->name); + + $filter = [ + [ + 'field' => 'location', + 'value' => [$queryString], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + // Then: Should include only assetA and assetB + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + + } + + public function testFilterAssetLocationArraySingle() + { + + // Given: Locations and assets + $locationA = Location::factory()->create(); + $locationB = Location::factory()->create(); + + $assetA = Asset::factory()->create(['location_id' => $locationA->id]); + $assetB = Asset::factory()->create(['location_id' => $locationB->id]); + + $filter = [ + [ + 'field' => 'location', + 'value' => [$locationA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + $results = Asset::query()->byFilter($filter)->get(); + + // Then: Should include only assetA and assetB + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + + } + + public function testFilterAssetLocationArrayMultiple() + { + + // Given: Locations and assets + $locationA = Location::factory()->create(); + $locationB = Location::factory()->create(); + $locationC = Location::factory()->create(); + $locationD = Location::factory()->create(); + $locationE = Location::factory()->create(); + + $assetA = Asset::factory()->create(['location_id' => $locationA->id]); + $assetB = Asset::factory()->create(['location_id' => $locationB->id]); + $assetC = Asset::factory()->create(['location_id' => $locationC->id]); + $assetD = Asset::factory()->create(['location_id' => $locationD->id]); + $assetE = Asset::factory()->create(['location_id' => $locationE->id]); + + // When: Query with an array of names + $filter = [ + [ + 'field' => 'location', + 'value' => [$locationB->id, $locationE->id], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + // Then: Should include only assetA to assetD + $this->assertCount(2, $results); + $this->assertTrue($results->contains($assetB)); + $this->assertTrue($results->contains($assetE)); + $this->assertFalse($results->contains($assetA)); + $this->assertFalse($results->contains($assetC)); + $this->assertFalse($results->contains($assetD)); + + } + + public function testFilterAssetLoationId() + { + + // Given: Locations and assets + $locationA = Location::factory()->create(); + $locationB = Location::factory()->create(); + + $assetA = Asset::factory()->create(['location_id' => $locationA->id]); + $assetB = Asset::factory()->create(['location_id' => $locationB->id]); + + $filter = [ + [ + 'field' => 'location', + 'value' => [$locationA->id], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + // Then: Should include only assetA and assetB + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + + } + + public function testFilterAssetLocationIdArraySingle() + { + + // Given: Locations and assets + $locationA = Location::factory()->create(); + $locationB = Location::factory()->create(); + + $assetA = Asset::factory()->create(['location_id' => $locationA->id]); + $assetB = Asset::factory()->create(['location_id' => $locationB->id]); + + $filter = [ + [ + 'field' => 'location', + 'value' => [$locationA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + // Then: Should include only assetA and assetB + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + + } + + public function testFilterAssetLocationIdAndNameArray() + { + // Given: Locations and assets + $locationA = Location::factory()->create(); + $locationB = Location::factory()->create(); + $locationC = Location::factory()->create(); + + $assetA = Asset::factory()->create(['location_id' => $locationA->id]); + $assetB = Asset::factory()->create(['location_id' => $locationB->id]); + $assetC = Asset::factory()->create(['location_id' => $locationC->id]); + + $filter = [ + [ + 'field' => 'location', + 'value' => [$locationA->id, $locationB->name], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + // Then: Should include only assetA and assetB + $this->assertCount(2, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + $this->assertFalse($results->contains($assetC)); + + } + +} \ No newline at end of file diff --git a/tests/Feature/AssetQuery/ManufacturerQueryTest.php b/tests/Feature/AssetQuery/ManufacturerQueryTest.php new file mode 100644 index 000000000000..b35a5dfaee98 --- /dev/null +++ b/tests/Feature/AssetQuery/ManufacturerQueryTest.php @@ -0,0 +1,269 @@ +create(); + $manufacturerB = Manufacturer::factory()->create(); + + $modelA = AssetModel::factory()->create(['manufacturer_id' => $manufacturerA->id]); + $modelB = AssetModel::factory()->create(['manufacturer_id' => $manufacturerB->id]); + + // Assets + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + + $filter = [ + [ + 'field' => 'manufacturer', + 'value' => [''], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(2, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + } + + + public function testFilterAssetManufacturerStringComplete() + { + $manufacturerA = Manufacturer::factory()->create(); + $manufacturerB = Manufacturer::factory()->create(); + + $modelA = AssetModel::factory()->create(['manufacturer_id' => $manufacturerA->id]); + $modelB = AssetModel::factory()->create(['manufacturer_id' => $manufacturerB->id]); + + // Assets + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + + $filter = [ + [ + 'field' => 'manufacturer', + 'value' => [$manufacturerA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + } + + public function testFilterAssetManufacturerStringPartial() + { + $manufacturerA = Manufacturer::factory()->create(); + $manufacturerB = Manufacturer::factory()->create(); + + $modelA = AssetModel::factory()->create(['manufacturer_id' => $manufacturerA->id]); + $modelB = AssetModel::factory()->create(['manufacturer_id' => $manufacturerB->id]); + + // Assets + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + + $queryString = ManufacturerQueryTest::getExtendedPrefix($manufacturerA->name, $manufacturerB->name); + + $filter = [ + [ + 'field' => 'manufacturer', + 'value' => [$queryString], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + } + + public function testFilterAssetManufacturerArraySingle() + { + + $manufacturerA = Manufacturer::factory()->create(); + $manufacturerB = Manufacturer::factory()->create(); + + $modelA = AssetModel::factory()->create(['manufacturer_id' => $manufacturerA->id]); + $modelB = AssetModel::factory()->create(['manufacturer_id' => $manufacturerB->id]); + + // Assets + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + + $filter = [ + [ + 'field' => 'manufacturer', + 'value' => [$manufacturerA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + + } + + public function testFilterAssetManufacturerArrayMultiple() + { + + $manufacturerA = Manufacturer::factory()->create(); + $manufacturerB = Manufacturer::factory()->create(); + $manufacturerC = Manufacturer::factory()->create(); + $manufacturerD = Manufacturer::factory()->create(); + $manufacturerE = Manufacturer::factory()->create(); + + $modelA = AssetModel::factory()->create(['manufacturer_id' => $manufacturerA->id]); + $modelB = AssetModel::factory()->create(['manufacturer_id' => $manufacturerB->id]); + $modelC = AssetModel::factory()->create(['manufacturer_id' => $manufacturerC->id]); + $modelD = AssetModel::factory()->create(['manufacturer_id' => $manufacturerD->id]); + $modelE = AssetModel::factory()->create(['manufacturer_id' => $manufacturerE->id]); + + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + $assetC = Asset::factory()->create(['model_id' => $modelC->id]); + $assetD = Asset::factory()->create(['model_id' => $modelD->id]); + $assetE = Asset::factory()->create(['model_id' => $modelE->id]); + + // When: Query with an array of names + $filter = [ + [ + 'field' => 'manufacturer', + 'value' => [$manufacturerE->id, $manufacturerB->id], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + $results = Asset::query()->byFilter($filter)->get(); + + // Then: Should include only assetA to assetD + $this->assertCount(2, $results); + $this->assertTrue($results->contains($assetB)); + $this->assertTrue($results->contains($assetE)); + $this->assertFalse($results->contains($assetA)); + $this->assertFalse($results->contains($assetC)); + $this->assertFalse($results->contains($assetD)); + + } + + public function testFilterAssetManufacturerId() + { + + $manufacturerA = Manufacturer::factory()->create(); + $manufacturerB = Manufacturer::factory()->create(); + + $modelA = AssetModel::factory()->create(['manufacturer_id' => $manufacturerA->id]); + $modelB = AssetModel::factory()->create(['manufacturer_id' => $manufacturerB->id]); + + // Assets + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + + $filter = [ + [ + 'field' => 'manufacturer', + 'value' => [$manufacturerA->id], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + + } + + public function testFilterAssetManufacturerIdArraySingle() + { + + $manufacturerA = Manufacturer::factory()->create(); + $manufacturerB = Manufacturer::factory()->create(); + + $modelA = AssetModel::factory()->create(['manufacturer_id' => $manufacturerA->id]); + $modelB = AssetModel::factory()->create(['manufacturer_id' => $manufacturerB->id]); + + // Assets + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + + $filter = [ + [ + 'field' => 'manufacturer', + 'value' => [$manufacturerA->id], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + + } + + public function testFilterAssetManufacturerIdAndNameArray() + { + $manufacturerA = Manufacturer::factory()->create(); + $manufacturerB = Manufacturer::factory()->create(); + $manufacturerC = Manufacturer::factory()->create(); + + $modelA = AssetModel::factory()->create(['manufacturer_id' => $manufacturerA->id]); + $modelB = AssetModel::factory()->create(['manufacturer_id' => $manufacturerB->id]); + $modelC = AssetModel::factory()->create(['manufacturer_id' => $manufacturerC->id]); + + // Assets + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + $assetC = Asset::factory()->create(['model_id' => $modelC->id]); + + $filter = [ + [ + 'field' => 'manufacturer', + 'value' => [$manufacturerA->id, $manufacturerB->name], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(2, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + $this->assertFalse($results->contains($assetC)); + + } +} \ No newline at end of file diff --git a/tests/Feature/AssetQuery/ModelNumberQueryTest.php b/tests/Feature/AssetQuery/ModelNumberQueryTest.php new file mode 100644 index 000000000000..a1da2108f261 --- /dev/null +++ b/tests/Feature/AssetQuery/ModelNumberQueryTest.php @@ -0,0 +1,156 @@ +create(); + $modelB = AssetModel::factory()->create(); + + // Assets + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + + $filter = [ + [ + 'field' => 'model_number', + 'value' => [''], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(2, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + } + + + public function testFilterAssetModelNumberStringComplete() + { + $modelA = AssetModel::factory()->create(); + $modelB = AssetModel::factory()->create(); + + // Assets + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + + $filter = [ + [ + 'field' => 'model_number', + 'value' => [$modelA->model_number], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + } + + public function testFilterAssetModelNumberStringPartial() + { + $modelA = AssetModel::factory()->create(); + $modelB = AssetModel::factory()->create(); + + // Assets + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + + $queryString = ModelNumberQueryTest::getExtendedPrefix($modelA->model_number, $modelB->model_number); + + $filter = [ + [ + 'field' => 'model_number', + 'value' => [$queryString], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + } + + public function testFilterAssetModelNumberArraySingle() + { + $modelA = AssetModel::factory()->create(); + $modelB = AssetModel::factory()->create(); + + // Assets + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + + $filter = [ + [ + 'field' => 'model_number', + 'value' => [$modelA->model_number], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + + } + + public function testFilterAssetModelNumberArrayMultiple() + { + + $modelA = AssetModel::factory()->create(); + $modelB = AssetModel::factory()->create(); + $modelC = AssetModel::factory()->create(); + $modelD = AssetModel::factory()->create(); + $modelE = AssetModel::factory()->create(); + + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + $assetC = Asset::factory()->create(['model_id' => $modelC->id]); + $assetD = Asset::factory()->create(['model_id' => $modelD->id]); + $assetE = Asset::factory()->create(['model_id' => $modelE->id]); + + // When: Query with an array of names + $filter = [ + [ + 'field' => 'model_number', + 'value' => [$modelB->model_number, $modelE->model_number], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + // Then: Should include only assetA to assetD + $this->assertCount(2, $results); + $this->assertTrue($results->contains($assetB)); + $this->assertTrue($results->contains($assetE)); + $this->assertFalse($results->contains($assetA)); + $this->assertFalse($results->contains($assetC)); + $this->assertFalse($results->contains($assetD)); + + } +} \ No newline at end of file diff --git a/tests/Feature/AssetQuery/ModelQueryTest.php b/tests/Feature/AssetQuery/ModelQueryTest.php new file mode 100644 index 000000000000..084291ae66a8 --- /dev/null +++ b/tests/Feature/AssetQuery/ModelQueryTest.php @@ -0,0 +1,231 @@ +create(); + $modelB = AssetModel::factory()->create(); + + // Assets + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + + $filter = [ + [ + 'field' => 'model', + 'value' => [''], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(2, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + } + + + public function testFilterAssetModelStringComplete() + { + $modelA = AssetModel::factory()->create(); + $modelB = AssetModel::factory()->create(); + + // Assets + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + + $filter = [ + [ + 'field' => 'model', + 'value' => [$modelA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + } + + public function testFilterAssetModelStringPartial() + { + $modelA = AssetModel::factory()->create(); + $modelB = AssetModel::factory()->create(); + + // Assets + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + + $queryString = substr($modelA->name, 0, floor(strlen($modelA->name) / 2)); + + $filter = [ + [ + 'field' => 'model', + 'value' => [$queryString], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + } + + public function testFilterAssetModelArraySingle() + { + $modelA = AssetModel::factory()->create(); + $modelB = AssetModel::factory()->create(); + + // Assets + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + + $filter = [ + [ + 'field' => 'model', + 'value' => [$modelA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ], + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + + } + + public function testFilterAssetModelArrayMultiple() + { + + $modelA = AssetModel::factory()->create(); + $modelB = AssetModel::factory()->create(); + $modelC = AssetModel::factory()->create(); + $modelD = AssetModel::factory()->create(); + $modelE = AssetModel::factory()->create(); + + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + $assetC = Asset::factory()->create(['model_id' => $modelC->id]); + $assetD = Asset::factory()->create(['model_id' => $modelD->id]); + $assetE = Asset::factory()->create(['model_id' => $modelE->id]); + + // When: Query with an array of names + $filter = [ + [ + 'field' => 'model', + 'value' => [$modelB->id, $modelE->id], + 'operator' => 'contains', + 'logic' => 'AND', + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + + // Then: Should include only assetA to assetD + $this->assertCount(2, $results); + $this->assertTrue($results->contains($assetB)); + $this->assertTrue($results->contains($assetE)); + $this->assertFalse($results->contains($assetA)); + $this->assertFalse($results->contains($assetC)); + $this->assertFalse($results->contains($assetD)); + + } + + public function testFilterAssetModelId() + { + $modelA = AssetModel::factory()->create(); + $modelB = AssetModel::factory()->create(); + + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + + // When: Query with an array of names + $filter = [ + [ + 'field' => 'model', + 'value' => [$modelB->id], + 'operator' => 'contains', + 'logic' => 'AND', + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + + // Then: Should include only assetA to assetD + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetB)); + $this->assertFalse($results->contains($assetA)); + } + + public function testFilterAssetModelIdArraySingle() + { + $modelA = AssetModel::factory()->create(); + $modelB = AssetModel::factory()->create(); + + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + + // When: Query with an array of names + $filter = [ + [ + 'field' => 'model', + 'value' => [$modelB->name], + 'operator' => 'contains', + 'logic' => 'AND', + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + + // Then: Should include only assetA to assetD + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetB)); + $this->assertFalse($results->contains($assetA)); + } + + public function testFilterAssetModelIdAndNameArray() + { + $modelA = AssetModel::factory()->create(); + $modelB = AssetModel::factory()->create(); + $modelC = AssetModel::factory()->create(); + + $assetA = Asset::factory()->create(['model_id' => $modelA->id]); + $assetB = Asset::factory()->create(['model_id' => $modelB->id]); + $assetC = Asset::factory()->create(['model_id' => $modelC->id]); + + // When: Query with an array of names + + $filter = [ + [ + 'field' => 'model', + 'value' => [$modelA->id, $modelB->name], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + // Then: Should include only assetA to assetD + $this->assertCount(2, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + $this->assertFalse($results->contains($assetC)); + } + +} \ No newline at end of file diff --git a/tests/Feature/AssetQuery/QueryLogicTest.php b/tests/Feature/AssetQuery/QueryLogicTest.php new file mode 100644 index 000000000000..22686705ede5 --- /dev/null +++ b/tests/Feature/AssetQuery/QueryLogicTest.php @@ -0,0 +1,181 @@ +create(['name' => 'Apple']); + $dell = Manufacturer::factory()->create(['name' => 'Dell']); + + $macbook = AssetModel::factory()->create(['name' => 'MacBook Pro', 'manufacturer_id' => $apple->id]); + $xps = AssetModel::factory()->create(['name' => 'XPS 15', 'manufacturer_id' => $dell->id]); + + $assetMacbook = Asset::factory()->create(['model_id' => $macbook->id]); + $assetDell = Asset::factory()->create(['model_id' => $xps->id]); + + $filter = [ + [ + 'field' => 'model', + 'value' => ['macbook'], + 'operator' => 'contains', + 'logic' => 'AND', + ], + [ + 'field' => 'manufacturer', + 'value' => ['Apple'], + 'operator' => 'contains', + 'logic' => 'AND', + ], + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetMacbook)); + $this->assertFalse($results->contains($assetDell)); + } + + public function testModelContainsAndManufacturerNotContains() + { + $apple = Manufacturer::factory()->create(['name' => 'Apple']); + $dell = Manufacturer::factory()->create(['name' => 'Dell']); + + $macbook = AssetModel::factory()->create(['name' => 'MacBook Pro', 'manufacturer_id' => $apple->id]); + $xps = AssetModel::factory()->create(['name' => 'XPS 15', 'manufacturer_id' => $dell->id]); + + $assetMacbook = Asset::factory()->create(['model_id' => $macbook->id]); + $assetDell = Asset::factory()->create(['model_id' => $xps->id]); + + $filter = [ + [ + 'field' => 'model', + 'value' => ['macbook'], + 'operator' => 'contains', + 'logic' => 'AND', + ], + [ + 'field' => 'manufacturer', + 'value' => ['Apple'], + 'operator' => 'contains', + 'logic' => 'NOT', + ], + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(0, $results); + } + + public function testPartialModelMatchAndExactManufacturer() + { + $apple = Manufacturer::factory()->create(['name' => 'Apple']); + $dell = Manufacturer::factory()->create(['name' => 'Dell']); + + $macbook = AssetModel::factory()->create(['name' => 'MacBook Pro', 'manufacturer_id' => $apple->id]); + $xps = AssetModel::factory()->create(['name' => 'XPS 15', 'manufacturer_id' => $dell->id]); + + $assetMacbook = Asset::factory()->create(['model_id' => $macbook->id]); + $assetDell = Asset::factory()->create(['model_id' => $xps->id]); + + $filter = [ + [ + 'field' => 'model', + 'value' => ['macb'], + 'operator' => 'contains', + 'logic' => 'AND', + ], + [ + 'field' => 'manufacturer', + 'value' => ['Apple'], + 'operator' => 'contains', + 'logic' => 'AND', + ], + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetMacbook)); + $this->assertFalse($results->contains($assetDell)); + } + + public function testPartialModelMatchAndManufacturerNotMatch() + { + $apple = Manufacturer::factory()->create(['name' => 'Apple']); + $dell = Manufacturer::factory()->create(['name' => 'Dell']); + + $macbook = AssetModel::factory()->create(['name' => 'MacBook Pro', 'manufacturer_id' => $apple->id]); + $xps = AssetModel::factory()->create(['name' => 'XPS 15', 'manufacturer_id' => $dell->id]); + + $assetMacbook = Asset::factory()->create(['model_id' => $macbook->id]); + $assetDell = Asset::factory()->create(['model_id' => $xps->id]); + + $filter = [ + [ + 'field' => 'model', + 'value' => ['macb'], + 'operator' => 'contains', + 'logic' => 'AND', + ], + [ + 'field' => 'manufacturer', + 'value' => ['Apple'], + 'operator' => 'contains', + 'logic' => 'NOT', + ], + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(0, $results); + } + + public function testModelContainsBookButExcludeAppleManufacturer() + { + $apple = Manufacturer::factory()->create(['name' => 'Apple']); + $microsoft = Manufacturer::factory()->create(['name' => 'Microsoft']); + $asus = Manufacturer::factory()->create(['name' => 'Asus']); + + $macbook = AssetModel::factory()->create(['name' => 'MacBook', 'manufacturer_id' => $apple->id]); + $surfacebook = AssetModel::factory()->create(['name' => 'SurfaceBook', 'manufacturer_id' => $microsoft->id]); + $zenbook = AssetModel::factory()->create(['name' => 'ZenBook', 'manufacturer_id' => $asus->id]); + + $assetMacbook = Asset::factory()->create(['model_id' => $macbook->id]); + $assetSurfacebook = Asset::factory()->create(['model_id' => $surfacebook->id]); + $assetZenbook = Asset::factory()->create(['model_id' => $zenbook->id]); + + $filter = [ + [ + 'field' => 'model', + 'value' => ['book'], + 'operator' => 'contains', + 'logic' => 'AND', + ], + [ + 'field' => 'manufacturer', + 'value' => ['Apple'], + 'operator' => 'contains', + 'logic' => 'NOT', + ], + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertTrue($results->contains($assetSurfacebook)); + $this->assertTrue($results->contains($assetZenbook)); + $this->assertFalse($results->contains($assetMacbook)); + } +} diff --git a/tests/Feature/AssetQuery/RtdLocationQueryTest.php b/tests/Feature/AssetQuery/RtdLocationQueryTest.php new file mode 100644 index 000000000000..457adabb0fb5 --- /dev/null +++ b/tests/Feature/AssetQuery/RtdLocationQueryTest.php @@ -0,0 +1,256 @@ +create(); + $locationB = Location::factory()->create(); + + $assetA = Asset::factory()->create(['rtd_location_id' => $locationA->id]); + $assetB = Asset::factory()->create(['rtd_location_id' => $locationB->id]); + + $filter = [ + [ + 'field' => 'rtd_location', + 'value' => [''], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + // Then: Should include only assetA and assetB + $this->assertCount(2, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + + } + + + public function testFilterAssetLocationStringComplete() + { + + // Given: Location and assets + $locationA = Location::factory()->create(); + $locationB = Location::factory()->create(); + + $assetA = Asset::factory()->create(['rtd_location_id' => $locationA->id]); + $assetB = Asset::factory()->create(['rtd_location_id' => $locationB->id]); + + $filter = [ + [ + 'field' => 'rtd_location', + 'value' => [$locationA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + + // Then: Should include only assetA and assetB + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + + } + + public function testFilterAssetLocationStringPartial() + { + + // Given: Locations and assets + $locationA = Location::factory()->create(); + $locationB = Location::factory()->create(); + + $assetA = Asset::factory()->create(['rtd_location_id' => $locationA->id]); + $assetB = Asset::factory()->create(['rtd_location_id' => $locationB->id]); + + $queryString = RtdLocationQueryTest::getExtendedPrefix($locationA->name, $locationB->name); + + $filter = [ + [ + 'field' => 'rtd_location', + 'value' => [$queryString], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + // Then: Should include only assetA and assetB + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + + } + + public function testFilterAssetLocationArraySingle() + { + + // Given: Locations and assets + $locationA = Location::factory()->create(); + $locationB = Location::factory()->create(); + + $assetA = Asset::factory()->create(['rtd_location_id' => $locationA->id]); + $assetB = Asset::factory()->create(['rtd_location_id' => $locationB->id]); + + $filter = [ + [ + 'field' => 'rtd_location', + 'value' => [$locationA->id], + 'operator' => 'contains', + 'logic' => 'AND', + ], + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + // Then: Should include only assetA and assetB + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + + } + + public function testFilterAssetLocationArrayMultiple() + { + + // Given: Locations and assets + $locationA = Location::factory()->create(); + $locationB = Location::factory()->create(); + $locationC = Location::factory()->create(); + $locationD = Location::factory()->create(); + $locationE = Location::factory()->create(); + + $assetA = Asset::factory()->create(['rtd_location_id' => $locationA->id]); + $assetB = Asset::factory()->create(['rtd_location_id' => $locationB->id]); + $assetC = Asset::factory()->create(['rtd_location_id' => $locationC->id]); + $assetD = Asset::factory()->create(['rtd_location_id' => $locationD->id]); + $assetE = Asset::factory()->create(['rtd_location_id' => $locationE->id]); + + // When: Query with an array of names + $filter = [ + [ + 'field' => 'rtd_location', + 'value' => [$locationB->id, $locationE->id], + 'operator' => 'contains', + 'logic' => 'AND', + ], + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + // Then: Should include only assetA to assetD + $this->assertCount(2, $results); + $this->assertTrue($results->contains($assetB)); + $this->assertTrue($results->contains($assetE)); + $this->assertFalse($results->contains($assetA)); + $this->assertFalse($results->contains($assetC)); + $this->assertFalse($results->contains($assetD)); + + } + + public function testFilterAssetLocationId() + { + + // Given: Locations and assets + $locationA = Location::factory()->create(); + $locationB = Location::factory()->create(); + + $assetA = Asset::factory()->create(['rtd_location_id' => $locationA->id]); + $assetB = Asset::factory()->create(['rtd_location_id' => $locationB->id]); + + $filter = [ + [ + 'field' => 'rtd_location', + 'value' => [$locationA->id], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + // Then: Should include only assetA and assetB + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + + } + + public function testFilterAssetLocationIdArraySingle() + { + + // Given: Locations and assets + $locationA = Location::factory()->create(); + $locationB = Location::factory()->create(); + + $assetA = Asset::factory()->create(['rtd_location_id' => $locationA->id]); + $assetB = Asset::factory()->create(['rtd_location_id' => $locationB->id]); + + $filter = [ + [ + 'field' => 'rtd_location', + 'value' => [$locationA->id], + 'operator' => 'contains', + 'logic' => 'AND', + ], + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + // Then: Should include only assetA and assetB + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + + } + + public function testFilterAssetLocationIdAndNameArray() + { + + // Given: Locations and assets + $locationA = Location::factory()->create(); + $locationB = Location::factory()->create(); + $locationC = Location::factory()->create(); + + $assetA = Asset::factory()->create(['rtd_location_id' => $locationA->id]); + $assetB = Asset::factory()->create(['rtd_location_id' => $locationB->id]); + $assetC = Asset::factory()->create(['rtd_location_id' => $locationC->id]); + + $filter = [ + [ + 'field' => 'rtd_location', + 'value' => [$locationA->id, $locationB->name], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + $results = Asset::query()->byFilter($filter)->get(); + + // Then: Should include only assetA and assetB + $this->assertCount(2, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + $this->assertFalse($results->contains($assetC)); + + } + +} \ No newline at end of file diff --git a/tests/Feature/AssetQuery/StatusLabelQueryTest.php b/tests/Feature/AssetQuery/StatusLabelQueryTest.php new file mode 100644 index 000000000000..5f51a5c9b10c --- /dev/null +++ b/tests/Feature/AssetQuery/StatusLabelQueryTest.php @@ -0,0 +1,293 @@ +create(); + $statusArchived = Statuslabel::factory()->create(); + + $assetA = Asset::factory()->create([ + 'status_id' => $statusPending->id, + ]); + + $assetB = Asset::factory()->create([ + 'status_id' => $statusArchived->id, + ]); + + // Act + $filter = [ + [ + 'field' => 'status_label', + 'value' => [''], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + // Assert + $this->assertTrue($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + } + + public function testFilterAssetStatusLabelStringComplete() + { + // Arrange: + $statusPending = Statuslabel::factory()->create(); + $statusArchived = Statuslabel::factory()->create(); + + $assetA = Asset::factory()->create([ + 'status_id' => $statusPending->id, + ]); + + $assetB = Asset::factory()->create([ + 'status_id' => $statusArchived->id, + ]); + + // Act + $filter = [ + [ + 'field' => 'status_label', + 'value' => [$statusPending->name], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + + $results = Asset::query()->byFilter($filter)->get(); + + // Assert + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + } + + public function testFilterAssetStatusLabelStringPartial() + { + // Arrange: + $statusPending = Statuslabel::factory()->create(); + $statusArchived = Statuslabel::factory()->create(); + + $assetA = Asset::factory()->create([ + 'status_id' => $statusPending->id, + ]); + + $assetB = Asset::factory()->create([ + 'status_id' => $statusArchived->id, + ]); + + // Act + $queryString = StatusLabelQueryTest::getExtendedPrefix($statusPending->name, $statusArchived->name); + $filter = [ + [ + 'field' => 'status_label', + 'value' => [$queryString], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + // Assert + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + } + + public function testFilterAssetStatusLabelArraySingle() + { + // Arrange: + $statusPending = Statuslabel::factory()->create(); + $statusArchived = Statuslabel::factory()->create(); + + $assetA = Asset::factory()->create([ + 'status_id' => $statusPending->id, + ]); + + $assetB = Asset::factory()->create([ + 'status_id' => $statusArchived->id, + ]); + + // Act + $filter = [ + [ + 'field' => 'status_label', + 'value' => [$statusPending->id], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + // Assert + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + } + + public function testFilterAssetStatusLabelArrayMultiple() + { + // Arrange: + $statusArchived = Statuslabel::factory()->create(); + $statusBroken = Statuslabel::factory()->create(); + $statusDeployed = Statuslabel::factory()->create(); + $statusPending = Statuslabel::factory()->create(); + $statusReadyToDeploy = Statuslabel::factory()->create(); + + $assetA = Asset::factory()->create([ + 'status_id' => $statusArchived->id, + ]); + + $assetB = Asset::factory()->create([ + 'status_id' => $statusBroken->id, + ]); + + $assetC = Asset::factory()->create([ + 'status_id' => $statusDeployed->id, + ]); + + $assetD = Asset::factory()->create([ + 'status_id' => $statusPending->id, + ]); + + $assetE = Asset::factory()->create([ + 'status_id' => $statusReadyToDeploy->id, + ]); + + // Act + $filter = [ + [ + 'field' => 'status_label', + 'value' => [$statusPending->id, $statusDeployed->id], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + // Assert + $this->assertCount(2, $results); + $this->assertTrue($results->contains($assetC)); + $this->assertTrue($results->contains($assetD)); + $this->assertFalse($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + $this->assertFalse($results->contains($assetE)); + } + + public function testFilterAssetStatusLabelId() + { + // Arrange: + $statusPending = Statuslabel::factory()->create(); + $statusArchived = Statuslabel::factory()->create(); + + $assetA = Asset::factory()->create([ + 'status_id' => $statusPending->id, + ]); + + $assetB = Asset::factory()->create([ + 'status_id' => $statusArchived->id, + ]); + + // Act + $filter = [ + [ + 'field' => 'status_label', + 'value' => [$statusPending->id], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + // Assert + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + } + + public function testFilterAssetStatusLabelIdArraySingle() + { + // Arrange: + $statusPending = Statuslabel::factory()->create(); + $statusArchived = Statuslabel::factory()->create(); + + $assetA = Asset::factory()->create([ + 'status_id' => $statusPending->id, + ]); + + $assetB = Asset::factory()->create([ + 'status_id' => $statusArchived->id, + ]); + + // Act + $filter = [ + [ + 'field' => 'status_label', + 'value' => [$statusPending->id], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + // Assert + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + } + + public function testFilterAssetStatusLabelIdAndNameArray() + { + // Arrange: + $statusPending = Statuslabel::factory()->create(); + $statusArchived = Statuslabel::factory()->create(); + $statusBroken = Statuslabel::factory()->create(); + + $assetA = Asset::factory()->create([ + 'status_id' => $statusPending->id, + ]); + + $assetB = Asset::factory()->create([ + 'status_id' => $statusArchived->id, + ]); + + $assetC = Asset::factory()->create([ + 'status_id' => $statusBroken->id, + ]); + + // Act + $filter = [ + [ + 'field' => 'status_label', + 'value' => [$statusPending->id, $statusArchived->name], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + // Assert + $this->assertCount(2, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + $this->assertFalse($results->contains($assetC)); + } +} \ No newline at end of file diff --git a/tests/Feature/AssetQuery/SupplierQueryTest.php b/tests/Feature/AssetQuery/SupplierQueryTest.php new file mode 100644 index 000000000000..0b8fd97986c7 --- /dev/null +++ b/tests/Feature/AssetQuery/SupplierQueryTest.php @@ -0,0 +1,274 @@ +create(); + $supplierB = Supplier::factory()->create(); + + $assetA = Asset::factory()->create([ + 'supplier_id' => $supplierA->id, + ]); + $assetB = Asset::factory()->create([ + 'supplier_id' => $supplierB->id, + ]); + + $filter = [ + [ + 'field' => 'supplier', + 'value' => [''], + 'operator' => 'contains', + 'logic' => 'AND', + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(2, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + } + + + public function testFilterAssetSupplierStringComplete() + { + $supplierA = Supplier::factory()->create(); + $supplierB = Supplier::factory()->create(); + + $assetA = Asset::factory()->create([ + 'supplier_id' => $supplierA->id, + ]); + $assetB = Asset::factory()->create([ + 'supplier_id' => $supplierB->id, + ]); + + + $filter = [ + [ + 'field' => 'supplier', + 'value' => [$supplierA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ], + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + } + + public function testFilterAssetSupplierStringPartial() + { + $supplierA = Supplier::factory()->create(); + $supplierB = Supplier::factory()->create(); + + $assetA = Asset::factory()->create([ + 'supplier_id' => $supplierA->id, + ]); + $assetB = Asset::factory()->create([ + 'supplier_id' => $supplierB->id, + ]); + + $queryString = SupplierQueryTest::getExtendedPrefix($supplierA->name, $supplierB->name); + + $filter = [ + [ + 'field' => 'supplier', + 'value' => [$queryString], + 'operator' => 'contains', + 'logic' => 'AND', + ], + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + } + + public function testFilterAssetSupplierArraySingle() + { + + $supplierA = Supplier::factory()->create(); + $supplierB = Supplier::factory()->create(); + + + $assetA = Asset::factory()->create([ + 'supplier_id' => $supplierA->id, + ]); + $assetB = Asset::factory()->create([ + 'supplier_id' => $supplierB->id, + ]); + + $filter = [ + [ + 'field' => 'supplier', + 'value' => [$supplierA->name], + 'operator' => 'contains', + 'logic' => 'AND', + ], + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + + } + + public function testFilterAssetSupplierArrayMultiple() + { + $supplierA = Supplier::factory()->create(); + $supplierB = Supplier::factory()->create(); + $supplierC = Supplier::factory()->create(); + $supplierD = Supplier::factory()->create(); + $supplierE = Supplier::factory()->create(); + + $assetA = Asset::factory()->create([ + 'supplier_id' => $supplierA->id, + ]); + $assetB = Asset::factory()->create([ + 'supplier_id' => $supplierB->id, + ]); + $assetC = Asset::factory()->create([ + 'supplier_id' => $supplierC->id, + ]); + $assetD = Asset::factory()->create([ + 'supplier_id' => $supplierD->id, + ]); + $assetE = Asset::factory()->create([ + 'supplier_id' => $supplierE->id, + ]); + + + $filter = [ + [ + 'field' => 'supplier', + 'value' => [$supplierB->id, $supplierE->id], + 'operator' => 'contains', + 'logic' => 'AND', + ], + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(2, $results); + $this->assertTrue($results->contains($assetB)); + $this->assertTrue($results->contains($assetE)); + $this->assertFalse($results->contains($assetA)); + $this->assertFalse($results->contains($assetC)); + $this->assertFalse($results->contains($assetD)); + + } + + public function testFilterAssetSupplierId() + { + + $supplierA = Supplier::factory()->create(); + $supplierB = Supplier::factory()->create(); + + + $assetA = Asset::factory()->create([ + 'supplier_id' => $supplierA->id, + ]); + $assetB = Asset::factory()->create([ + 'supplier_id' => $supplierB->id, + ]); + + $filter = [ + [ + 'field' => 'supplier', + 'value' => [$supplierA->id], + 'operator' => 'contains', + 'logic' => 'AND', + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + + } + + public function testFilterAssetSupplierIdArraySingle() + { + + $supplierA = Supplier::factory()->create(); + $supplierB = Supplier::factory()->create(); + + + $assetA = Asset::factory()->create([ + 'supplier_id' => $supplierA->id, + ]); + $assetB = Asset::factory()->create([ + 'supplier_id' => $supplierB->id, + ]); + + $filter = [ + [ + 'field' => 'supplier', + 'value' => [$supplierA->id], + 'operator' => 'contains', + 'logic' => 'AND', + ], + ]; + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(1, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertFalse($results->contains($assetB)); + + } + + public function testFilterAssetSupplierIdAndNameArray() + { + $supplierA = Supplier::factory()->create(); + $supplierB = Supplier::factory()->create(); + $supplierC = Supplier::factory()->create(); + + + $assetA = Asset::factory()->create([ + 'supplier_id' => $supplierA->id, + ]); + $assetB = Asset::factory()->create([ + 'supplier_id' => $supplierB->id, + ]); + $assetC = Asset::factory()->create([ + 'supplier_id' => $supplierC->id, + ]); + + $filter = [ + [ + 'field' => 'supplier', + 'value' => [$supplierA->id, $supplierB->name], + 'operator' => 'contains', + 'logic' => 'AND', + ] + ]; + + $results = Asset::query()->byFilter($filter)->get(); + + $this->assertCount(2, $results); + $this->assertTrue($results->contains($assetA)); + $this->assertTrue($results->contains($assetB)); + $this->assertFalse($results->contains($assetC)); + + } + +} \ No newline at end of file diff --git a/tests/Feature/PredefinedFilter/Api/PredefinedFilterControllerTest.php b/tests/Feature/PredefinedFilter/Api/PredefinedFilterControllerTest.php new file mode 100644 index 000000000000..b0af1e7ae963 --- /dev/null +++ b/tests/Feature/PredefinedFilter/Api/PredefinedFilterControllerTest.php @@ -0,0 +1,1035 @@ +create([ + 'permissions' => json_encode($perms), + ]); + $user->groups()->attach($g->id); + return $g; + } + + private function linkGroupFilter(PredefinedFilter $f, PermissionGroup $g) + { + DB::table('predefined_filter_permissions')->insert([ + 'predefined_filter_id' => $f->id, + 'permission_group_id' => $g->id, + 'created_by' => $f->created_by, + ]); + } + + //------INDEX TESTS------ + + public function testIndexOkWithPublicAndViewPermission(): void + { + $u = User::factory()->create(); + $g = $this->grant($u, ['predefinedFilter.view' => '1']); + + $f = PredefinedFilter::factory()->create(['is_public' => 1]); + $this->linkGroupFilter($f, $g); + + $this->actingAs($u, 'api') + ->getJson('/api/v1/predefinedFilters') + ->assertOk(); + } + + public function testIndexUnauthenticatedGets302() + { + $this->getJson('api/v1/predefinedFilters')->assertStatus(302); + + } + + public function testIndexEmptyReturnsEmptyArray() + { + $u = User::factory()->create(); + + $this->actingAs($u, 'api') + ->getJson('/api/v1/predefinedFilters') + ->assertOk() + ->assertExactJson(['rows' => [], 'total' => 0,]); + } + + public function testIndexListsOnlyViewableOrOwned(): void + { + $owner = User::factory()->create(); + $u = User::factory()->create(); + $g = $this->grant($u, ['predefinedFilter.view' => '1']); + + $viewable = PredefinedFilter::factory()->create([ + 'name' => 'A Viewable', + 'created_by' => $owner->id, + 'is_public' => 1, + ]); + $this->linkGroupFilter($viewable, $g); + + $hidden = PredefinedFilter::factory()->create([ + 'name' => 'Z Hidden', + 'created_by' => $owner->id, + 'is_public' => 0, + ]); + + $mine = PredefinedFilter::factory()->create([ + 'name' => 'M My Own', + 'created_by' => $u->id, + 'is_public' => 0, + ]); + + $response = $this->actingAs($u, 'api') + ->getJson('/api/v1/predefinedFilters') + ->assertOk() + ->assertJsonFragment(['id' => $viewable->id, 'name' => 'A Viewable']) + ->assertJsonFragment(['id' => $mine->id, 'name' => 'M My Own']); + $this->assertCount(2, $response->json('rows')); + } + + public function testIndexListsOnlyPublicLinkedOrOwned(): void + { + $owner = User::factory()->create(); + $user = User::factory()->create(); + $g = $this->grant($user, ['predefinedFilter.view' => '1']); + + $viewable = PredefinedFilter::factory()->create([ + 'name' => 'Viewable Filter', + 'created_by' => $owner->id, + 'is_public' => 1, + ]); + $this->linkGroupFilter($viewable, $g); + + $hidden = PredefinedFilter::factory()->create([ + 'name' => 'Hidden Filter', + 'created_by' => $owner->id, + 'is_public' => 0, + ]); + + $mine = PredefinedFilter::factory()->create([ + 'name' => 'My Own', + 'created_by' => $user->id, + 'is_public' => 0, + ]); + + $this->actingAs($user, 'api') + ->getJson('/api/v1/predefinedFilters') + ->assertOk() + ->assertJsonFragment(['name' => 'Viewable Filter']) + ->assertJsonFragment(['name' => 'My Own']) + ->assertJsonMissing(['name' => 'Hidden Filter']); + } + + public function testSuCanSeePrivateFilter(){ + $superuser = User::factory()->superuser()->create(); + $privateOwner = User::factory()->create(); + + $filter = PredefinedFilter::factory()->create([ + 'name'=>'Allowed Private Filter', + 'created_by'=>$privateOwner->id, + 'is_public'=>0, + ]); + + $this->actingAs($superuser,'api') + ->getJson("/api/v1/predefinedFilters") + ->assertStatus(200) + ->assertJsonFragment(['name'=>'Allowed Private Filter']); + } + + public function testIndexCanSearchByName(): void + { + $user = User::factory()->create(); + + $match = PredefinedFilter::factory()->create([ + 'name' => 'Important Filter', + 'is_public' => 0, + 'created_by' => $user->id, + ]); + $noMatch = PredefinedFilter::factory()->create([ + 'name' => 'Unrelated', + 'is_public' => 0, + 'created_by' => $user->id, + ]); + + $response = $this->actingAs($user, 'api') + ->getJson('/api/v1/predefinedFilters?search=important') + ->assertOk(); + + $response->assertJsonFragment(['name' => 'Important Filter']); + $response->assertJsonMissing(['name' => 'Unrelated']); + $this->assertCount(1, $response->json('rows')); + } + + public function testIndexCanSortResultsByName(): void + { + $user = User::factory()->create(); + + $a = PredefinedFilter::factory()->create(['name' => 'Alpha', 'is_public' => 0, 'created_by' => $user->id]); + $z = PredefinedFilter::factory()->create(['name' => 'Zulu', 'is_public' => 0, 'created_by' => $user->id]); + $m = PredefinedFilter::factory()->create(['name' => 'Mike', 'is_public' => 0, 'created_by' => $user->id]); + + // Ascending + $asc = $this->actingAs($user, 'api') + ->getJson('/api/v1/predefinedFilters?sort=name&order=asc') + ->assertOk() + ->json('rows'); + + $this->assertEquals(['Alpha', 'Mike', 'Zulu'], array_column($asc, 'name')); + + // Descending + $desc = $this->actingAs($user, 'api') + ->getJson('/api/v1/predefinedFilters?sort=name&order=desc') + ->assertOk() + ->json('rows'); + + $this->assertEquals(['Zulu', 'Mike', 'Alpha'], array_column($desc, 'name')); + } + + public function testIndexCanPaginateResults(): void + { + $user = User::factory()->create(); + + $filters = PredefinedFilter::factory()->count(5)->sequence( + ['name' => 'Filter 1', 'is_public' => 0], + ['name' => 'Filter 2', 'is_public' => 0], + ['name' => 'Filter 3', 'is_public' => 0], + ['name' => 'Filter 4', 'is_public' => 0], + ['name' => 'Filter 5', 'is_public' => 0], + )->create(['created_by' => $user->id]); + + $response1 = $this->actingAs($user, 'api') + ->getJson('/api/v1/predefinedFilters?limit=2&offset=0') + ->assertOk(); + + $this->assertCount(2, $response1->json('rows')); + $this->assertEquals(5, $response1->json('total')); + + $response2 = $this->actingAs($user, 'api') + ->getJson('/api/v1/predefinedFilters?limit=2&offset=2') + ->assertOk(); + + $this->assertCount(2, $response2->json('rows')); + $this->assertEquals(5, $response2->json('total')); + } + + //------SHOW TESTS------ + + public function testShow404WhenMissing(): void + { + $u = User::factory()->create(); + $this->grant($u, ['predefinedFilter.view' => '1']); + + $this->actingAs($u, 'api') + ->getJson('/api/v1/predefinedFilters/999999') + ->assertStatus(404) + ->assertJson(['message' => 'Filter does not exist.']); + } + + public function testShowForbiddenWithoutViewPermission(): void + { + $user = User::factory()->create(); + $owner = User::factory()->create(); + + $filter = PredefinedFilter::factory()->create([ + 'created_by' => $owner->id, + 'filter_data' => ['status_id' => [1]], + ]); + + $this->actingAs($user, 'api') + ->getJson("/api/v1/predefinedFilters/{$filter->id}") + ->assertStatus(403); + } + + public function testShowOkAsOwnerWithoutPublicOrView(): void + { + $u = User::factory()->create(); + $f = PredefinedFilter::factory()->create([ + 'created_by' => $u->id, + 'is_public' => 0, + ]); + + $this->actingAs($u, 'api') + ->getJson("/api/v1/predefinedFilters/{$f->id}") + ->assertOk() + ->assertJsonFragment(['id' => $f->id, 'name' => $f->name]); + } + + public function testShowForbiddenWithoutViewOrNotPublic() + { + $owner = User::factory()->create(); + $u = User::factory()->create(); + + $f = PredefinedFilter::factory()->create([ + 'created_by' => $owner->id, + 'is_public' => 0, + ]); + + $this->actingAs($u, 'api') + ->getJson("/api/v1/predefinedFilters/{$f->id}") + ->assertStatus(403) + ->assertJson(['message' => trans('admin/predefinedFilters/message.show.not_allowed')]); + } + + public function testShowOkAsNonOwnerWhenPublicAndView() + { + $owner = User::factory()->create(); + $u = User::factory()->create(); + $g = $this->grant($u, ['predefinedFilter.view' => '1']); + + $f = PredefinedFilter::factory()->create([ + 'created_by' => $owner->id, + 'is_public' => 1, + ]); + $this->linkGroupFilter($f, $g); + + $this->actingAs($u, 'api') + ->getJson("/api/v1/predefinedFilters/{$f->id}") + ->assertOk() + ->assertJsonFragment(['id' => $f->id, 'name' => $f->name]); + } + + public function testShowForbiddenWhenPrivateAndNotOwner(): void + { + $userWithout = User::factory()->create(); + $owner = User::factory()->create(); + + $filter = PredefinedFilter::factory()->create([ + 'created_by' => $owner->id, + 'is_public' => 0, + ]); + + $this->actingAs($userWithout, 'api') + ->getJson("/api/v1/predefinedFilters/{$filter->id}") + ->assertStatus(403) + ->assertJson(['message' => trans('admin/predefinedFilters/message.show.not_allowed')]); + } + + public function testShowNonOwnerPublicWithViewIsOk(): void + { + $owner = User::factory()->create(); + $user = User::factory()->create(); + $g = $this->grant($user, ['predefinedFilter.view' => '1']); + + $filter = PredefinedFilter::factory()->create([ + 'name' => 'Allowed Public Filter', + 'created_by' => $owner->id, + 'is_public' => 1, + ]); + $this->linkGroupFilter($filter, $g); + + $this->actingAs($user, 'api') + ->getJson("/api/v1/predefinedFilters/{$filter->id}") + ->assertOk() + ->assertJsonFragment(['name' => 'Allowed Public Filter']); + } + + public function testShowForbiddenForPrivateNonOwner(): void + { + $owner = User::factory()->create(); + $user = User::factory()->create(); + + $filter = PredefinedFilter::factory()->create([ + 'name' => 'Private Filter', + 'created_by' => $owner->id, + 'is_public' => 0, + ]); + + $this->actingAs($user, 'api') + ->getJson("/api/v1/predefinedFilters/{$filter->id}") + ->assertStatus(403) + ->assertJson(['message' => trans('admin/predefinedFilters/message.show.not_allowed')]); + } + + public function testShowReturns404IfFilterNotFound(): void + { + $user = User::factory()->create(); + + $this->actingAs($user, 'api') + ->getJson('/api/v1/predefinedFilters/404') + ->assertStatus(404) + ->assertJson(['message' => trans('admin/predefinedFilters/message.does_not_exist')]); + } + + public function testSuCanShowPrivateFilter() + { + $superuser = User::factory()->superuser()->create(); + $privateOwner = User::factory()->create(); + + $filter = PredefinedFilter::factory()->create([ + 'name'=>'Allowed Private Filter', + 'created_by'=>$privateOwner->id, + 'is_public'=>0, + 'filter_data' => ['Allowed Private Filter_Data'] + ]); + + $this->actingAs($superuser,'api') + ->getJson("/api/v1/predefinedFilters/{$filter->id}") + ->assertStatus(200) + ->assertJsonFragment(['name'=>'Allowed Private Filter']) + ->assertJsonFragment(['filter_data'=>['Allowed Private Filter_Data']]); + } + + + //------STORE TESTS------ + + public function testStoreValidatesPayload() + { + $u = User::factory()->create(); + + $this->actingAs($u, 'api') + ->postJson('/api/v1/predefinedFilters', []) + ->assertStatus(422) + ->assertJsonPath('messages.name.0', 'The name field is required.') + ->assertJsonPath('messages.filter_data.0', 'The filter data field is required.'); + } + + public function testStoreWithTooLongNameError() + { + $u = User::factory()->create(); + $this->grant($u, ['predefinedFilter.create' => '1']); + + $this->actingAs($u, 'api') + ->postJson(route('api.predefined-filters.store'), [ + 'name' => 'Testing ensures software works as intended, catching hidden bugs early and preventing costly failures. It builds confidence, improves quality, and supports reliable, user-focused products overall.', + 'filter_data' => ['status_id' => [1, 2]], + 'is_public' => 1, + 'created_by' => 999, + ]) + ->assertStatus(422) + ->assertJsonPath('messages.name.0', 'The name field must not be greater than 191 characters.'); + } + + public function testStoreCreatesAndSetsOwner() + { + $u = User::factory()->create(); + $this->grant($u, ['predefinedFilter.create' => '1']); + + $this->actingAs($u, 'api') + ->postJson(route('api.predefined-filters.store'), [ + 'name' => 'Neu', + 'filter_data' => ['status_id' => [1, 2]], + 'is_public' => 1, + 'created_by' => 999, + ]) + ->assertCreated() + ->assertJsonPath('filter_data.name', 'Neu') + ->assertJsonPath('filter_data.is_public', true) + ->assertJsonPath('filter_data.created_by', $u->id); + } + + public function testStorePublicRequiresCreatePermission() + { + $u = User::factory()->create(); + + $this->actingAs($u, 'api') + ->postJson(route('api.predefined-filters.store'), [ + 'name' => 'X', + 'filter_data' => ['a' => 1], + 'is_public' => 1, + ]) + ->assertStatus(403) + ->assertJson(['message' => trans('admin/predefinedFilters/message.create.not_allowed')]); + } + public function testStorePublicWithCreatePermissionReturns201(): void + { + $user = User::factory()->create(); + $this->grant($user, ['predefinedFilter.create' => '1']); + + $payload = [ + 'name' => 'Test Public Filter', + 'filter_data' => ['status_id' => [1]], + 'is_public' => true, + ]; + + $this->actingAs($user, 'api') + ->postJson('/api/v1/predefinedFilters', $payload) + ->assertCreated() + ->assertJsonPath('filter_data.name', 'Test Public Filter') + ->assertJsonPath('filter_data.is_public', true) + ->assertJsonPath('filter_data.created_by', $user->id); + } + + public function testStorePublicWithoutCreatePermissionReturns403(): void + { + $user = User::factory()->create(); + + $payload = [ + 'name' => 'Unauthorized Public Filter', + 'filter_data' => ['status_id' => [1]], + 'is_public' => true, + ]; + + $this->actingAs($user, 'api') + ->postJson('/api/v1/predefinedFilters', $payload) + ->assertStatus(403) + ->assertJson(['message' => trans('admin/predefinedFilters/message.create.not_allowed')]); + } + + //------UPDATE TESTS------ + + public function testUpdateOwnerPrivateToPublicRequiresCreate(): void + { + $u = User::factory()->create(); + $f = PredefinedFilter::factory()->create([ + 'created_by' => $u->id, + 'is_public' => 0, + 'name' => 'Old', + 'filter_data' => ['a' => 1], + ]); + + $this->actingAs($u, 'api') + ->putJson("/api/v1/predefinedFilters/{$f->id}", [ + 'name' => 'New', + 'filter_data' => ['a' => 2], + 'is_public' => 1 + ]) + ->assertStatus(403); + + $this->grant($u, ['predefinedFilter.create' => '1']); + $u->refresh(); + + $this->actingAs($u->fresh(), 'api') + ->putJson("/api/v1/predefinedFilters/{$f->id}", [ + 'name' => 'New', + 'filter_data' => ['a' => 2], + 'is_public' => 1 + ]) + ->assertOk() + ->assertJsonPath('filter_data.is_public', true) + ->assertJsonPath('filter_data.name', 'New'); + } + + public function testUpdateNonOwnerPublicRequiresUpdatePermission(): void + { + $owner = User::factory()->create(); + $other = User::factory()->create(); + + $f = PredefinedFilter::factory()->create([ + 'created_by' => $owner->id, + 'is_public' => 1, + 'name' => 'Old', + 'filter_data' => ['a' => 1], + ]); + + $this->actingAs($other, 'api') + ->putJson("/api/v1/predefinedFilters/{$f->id}", [ + 'name' => 'X', + 'filter_data' => ['a' => 3], + 'is_public' => 1 + ]) + ->assertStatus(403); + + $g = $this->grant($other, ['predefinedFilter.edit' => '1']); + $this->linkGroupFilter($f, $g); + + $this->actingAs($other, 'api') + ->putJson("/api/v1/predefinedFilters/{$f->id}", [ + 'name' => 'X', + 'filter_data' => ['a' => 3], + 'is_public' => 1 + ]) + ->assertOk() + ->assertJsonPath('filter_data.name', 'X'); + } + + public function testUpdate404WhenMissing(): void + { + $u = User::factory()->create(); + $this->actingAs($u, 'api') + ->putJson('/api/v1/predefinedFilters/999999', [ + 'name' => 'X', + 'filter_data' => [], + 'is_public' => 0 + ]) + ->assertStatus(404) + ->assertJson(['message' => 'Filter does not exist.']); + } + + public function testUpdateValidatesPayload(): void + { + $u = User::factory()->create(); + $f = PredefinedFilter::factory()->create(); + + $this->actingAs($u, 'api') + ->putJson(route('api.predefined-filters.update', ['id' => $f->id]), []); + + $this->actingAs($u, 'api') + ->putJson(route('api.predefined-filters.update', ['id' => $f->id]), []) + ->assertStatus(422) + ->assertJsonPath('messages.name.0', 'The name field is required.') + ->assertJsonPath('messages.filter_data.0', 'The filter data field is required.'); + } + + public function testUpdateNameTooLong(): void + { + $u = User::factory()->create(); + $f = PredefinedFilter::factory()->create(); + // First request (valid name) + $this->actingAs($u, 'api') + ->putJson(route('api.predefined-filters.update', $f->id), [ + 'name' => 'Filter', + 'filter_data' => ['status_id' => [1, 2]], + 'is_public' => 1, + 'created_by' => 999, + ]); + + // Second request (name too long) + $this->actingAs($u, 'api') + ->putJson(route('api.predefined-filters.update', $f->id), [ + 'name' => 'Testing ensures software works as intended, catching hidden bugs early and preventing costly failures. It builds confidence, improves quality, and supports reliable, user-focused products overall.', + 'filter_data' => ['status_id' => [1, 2]], + 'is_public' => 1, + 'created_by' => 999, + ]) + ->assertStatus(422) + ->assertJsonPath('messages.name.0', 'The name field must not be greater than 191 characters.'); + } + + + //------DESTROY TESTS------ + public function testDestroyNonOwnerPublicRequiresDestroyPermission() + { + $owner = User::factory()->create(); + $other = User::factory()->create(); + + $f = PredefinedFilter::factory()->create([ + 'created_by' => $owner->id, + 'is_public' => 1, + ]); + + $this->actingAs($other, 'api') + ->deleteJson("/api/v1/predefinedFilters/{$f->id}") + ->assertStatus(403); + + $g = $this->grant($other, ['predefinedFilter.delete' => '1']); + $this->linkGroupFilter($f, $g); + + $this->actingAs($other, 'api') + ->deleteJson("/api/v1/predefinedFilters/{$f->id}") + ->assertOk() + ->assertJson([ + 'message' => trans('admin/predefinedFilters/message.delete.success'), + ]); + + $this->assertSoftDeleted('predefined_filters', ['id' => $f->id]); + } + + public function testDestroy404WhenMissing() + { + $u = User::factory()->create(); + + $this->actingAs($u, 'api') + ->deleteJson('/api/v1/predefinedFilters/999999') + ->assertStatus(404) + ->assertJson(['message' => trans('admin/predefinedFilters/message.does_not_exist')]); + } + + public function testDestroyOwnerPrivateOk200() + { + $u = User::factory()->create(); + $f = PredefinedFilter::factory()->create(['created_by' => $u->id, 'is_public' => 0, 'filter_data' => [['a' => 'a']]]); + + $this->actingAs($u, 'api') + ->deleteJson("/api/v1/predefinedFilters/{$f->id}") + ->assertOk() + ->assertJson(['message' => trans('admin/predefinedFilters/message.delete.success')]); + + $this->assertSoftDeleted('predefined_filters', ['id' => $f->id]); + } + + // PermissionStructureTests + public function testTransformWithLoadedPermissionGroupsStructure() + { + $this->transformer = new PredefinedFiltersTransformer(); + + $user = User::factory()->create(); + $this->actingAs($user); + + // Create creator user (could be same as current user or different) + $creator = User::factory()->create(); + + // Create some permission groups (assuming you have a factory) + $permissionGroup1 = PermissionGroup::factory()->create(['name' => 'Group 1']); + $permissionGroup2 = PermissionGroup::factory()->create(['name' => 'Group 2']); + + // Create the filter with JSON-encoded filter_data + $filter = PredefinedFilter::factory()->create([ + 'created_by' => $creator->id, + 'filter_data' => json_encode(['foo' => 'bar']), + 'is_public' => true, + 'object_type' => 'test_type', + ]); + + // Manually set relations + $filter->setRelation('createdBy', $creator); + $filter->setRelation('permissionGroups', collect([$permissionGroup1, $permissionGroup2])); + + // Partial mock to stub userHasPermission as false to focus on structure + $filter = \Mockery::mock($filter)->makePartial(); + $filter->shouldReceive('userHasPermission')->with($user, 'edit')->andReturn(false); + $filter->shouldReceive('userHasPermission')->with($user, 'delete')->andReturn(false); + + $result = $this->transformer->transformPredefinedFilter($filter); + + // Check main keys exist and types + $this->assertIsArray($result); + $this->assertArrayHasKey('id', $result); + $this->assertArrayHasKey('name', $result); + $this->assertArrayHasKey('filter_data', $result); + $this->assertArrayHasKey('is_public', $result); + $this->assertArrayHasKey('object_type', $result); + $this->assertArrayHasKey('created_by', $result); + $this->assertArrayHasKey('created_at', $result); + $this->assertArrayHasKey('updated_at', $result); + $this->assertArrayHasKey('deleted_at', $result); + $this->assertArrayHasKey('groups', $result); + $this->assertArrayHasKey('available_actions', $result); + + // Validate groups structure + $this->assertIsArray($result['groups']); + $this->assertEquals(2, $result['groups']['total']); + $this->assertCount(2, $result['groups']['rows']); + + $this->assertEquals($permissionGroup1->id, $result['groups']['rows'][0]['id']); + $this->assertEquals($permissionGroup1->name, $result['groups']['rows'][0]['name']); + + $this->assertEquals($permissionGroup2->id, $result['groups']['rows'][1]['id']); + $this->assertEquals($permissionGroup2->name, $result['groups']['rows'][1]['name']); + + // Confirm available actions are false (since userHasPermission mocked false and not owner) + $this->assertFalse($result['available_actions']['update']); + $this->assertFalse($result['available_actions']['delete']); + } + + public function testTransformWithoutPermissionGroupsLoadedSetsgroupsNull() + { + $this->transformer = new PredefinedFiltersTransformer(); + + $user = User::factory()->create(); + $this->actingAs($user); + + $filter = PredefinedFilter::factory()->create([ + 'created_by' => User::factory()->create()->id, // different user + 'filter_data' => json_encode([['foo' => 'bar']]), + ]); + + $filter->setRelation('createdBy', $filter->created_by ? User::find($filter->created_by) : null); + + $filter = \Mockery::mock($filter)->makePartial(); + $filter->shouldReceive('userHasPermission')->andReturn(false); + + // Intentionally do NOT load permissionGroups relationship + + $result = $this->transformer->transformPredefinedFilter($filter); + + $this->assertNull($result['groups']); + $this->assertArrayHasKey('available_actions', $result); + $this->assertFalse($result['available_actions']['update']); + $this->assertFalse($result['available_actions']['delete']); + } + + public function testTransformSetsAvailableActionsFalseForOwner() + { + $this->transformer = new PredefinedFiltersTransformer(); + + $user = User::factory()->create(); + $this->actingAs($user); + + $filter = PredefinedFilter::factory()->create(['created_by' => $user->id, 'filter_data' => json_encode([['foo' => 'bar']])]); + + $filter->setRelation('createdBy', $user); + + // Make sure userHasPermission returns false, but user is owner + $filter = \Mockery::mock($filter)->makePartial(); + $filter->shouldReceive('userHasPermission')->andReturn(false); + + $result = $this->transformer->transformPredefinedFilter($filter); + + $this->assertFalse($result['available_actions']['update']); + $this->assertFalse($result['available_actions']['delete']); + } + + public function testTransformFormatsDatesCorrectly() + { + $this->transformer = new PredefinedFiltersTransformer(); + + $user = User::factory()->create(); + $this->actingAs($user); + $filter = PredefinedFilter::factory()->create(['created_by' => $user->id, 'filter_data' => json_encode([['foo' => 'bar']])]); + + $filter->setRelation('createdBy', $user); + + $filter = \Mockery::mock($filter)->makePartial(); + $filter->shouldReceive('userHasPermission')->andReturn(false); + + $result = $this->transformer->transformPredefinedFilter($filter); + + $this->assertArrayHasKey('created_at', $result); + $this->assertArrayHasKey('updated_at', $result); + $this->assertArrayHasKey('deleted_at', $result); + } + + //------SELECTLIST TESTS------ + + public function testSelectlist() + { + $owner = User::factory()->create(); + $grant = $this->grant($owner, ['predefinedFilter.view' => '1', 'predefinedFilter.create' => '1', 'predefinedFilter.edit' => '1']); + + $publicFilterA = PredefinedFilter::factory()->create([ + 'name' => 'All coffee machines', + 'created_by' => $owner->id, + 'is_public' => 1, + ]); + + $publicFilterB = PredefinedFilter::factory()->create([ + 'name' => 'Desktops', + 'created_by' => $owner->id, + 'is_public' => 1, + ]); + + $privateFilterA = PredefinedFilter::factory()->create([ + 'name' => 'Laptops', + 'created_by' => $owner->id, + 'is_public' => 0, + ]); + + $privateFilterB = PredefinedFilter::factory()->create([ + 'name' => 'Coffee mugs', + 'created_by' => $owner->id, + 'is_public' => 0, + ]); + + $this->linkGroupFilter($publicFilterA, $grant); + $this->linkGroupFilter($publicFilterB, $grant); + + $response = $this->actingAs($owner, 'api') + ->getJson('/api/v1/predefinedFilters/selectlist'); + + $response->assertOk() + ->assertJsonFragment(['id' => $publicFilterA->id, 'text' => $publicFilterA->name . " (Public)"]) + ->assertJsonFragment(['id' => $publicFilterB->id, 'text' => $publicFilterB->name . " (Public)"]) + ->assertJsonFragment(['id' => $privateFilterA->id, 'text' => $privateFilterA->name . " (Private)"]) + ->assertJsonFragment(['id' => $privateFilterB->id, 'text' => $privateFilterB->name . " (Private)"]); + + $this->assertCount(4, $response->json('results')); + } + + public function testSelectlistSearch() + { + $owner = User::factory()->create(); + $grant = $this->grant($owner, ['predefinedFilter.view' => '1']); + + $publicFilterA = PredefinedFilter::factory()->create([ + 'name' => 'All coffee machines', + 'created_by' => $owner->id, + 'is_public' => 1, + ]); + + $publicFilterB = PredefinedFilter::factory()->create([ + 'name' => 'Desktops', + 'created_by' => $owner->id, + 'is_public' => 1, + ]); + + $privateFilterA = PredefinedFilter::factory()->create([ + 'name' => 'Laptops', + 'created_by' => $owner->id, + 'is_public' => 0, + ]); + + $privateFilterB = PredefinedFilter::factory()->create([ + 'name' => 'Coffee mugs', + 'created_by' => $owner->id, + 'is_public' => 0, + ]); + + $this->linkGroupFilter($publicFilterA, $grant); + $this->linkGroupFilter($publicFilterB, $grant); + + $response = $this->actingAs($owner, 'api') + ->getJson('/api/v1/predefinedFilters/selectlist?search=coffee&page=1'); + + $response->assertOk() + ->assertJsonFragment(['id' => $publicFilterA->id, 'text' => $publicFilterA->name . " (Public)"]) + ->assertJsonFragment(['id' => $privateFilterB->id, 'text' => $privateFilterB->name . " (Private)"]); + + $this->assertCount(2, $response->json('results')); + } + + public function testSelectlistPrivate() + { + $owner = User::factory()->create(); + $grant = $this->grant($owner, ['predefinedFilter.view' => '1']); + + $publicFilterA = PredefinedFilter::factory()->create([ + 'name' => 'All coffee machines', + 'created_by' => $owner->id, + 'is_public' => 1, + ]); + + $publicFilterB = PredefinedFilter::factory()->create([ + 'name' => 'Desktops', + 'created_by' => $owner->id, + 'is_public' => 1, + ]); + + $privateFilterA = PredefinedFilter::factory()->create([ + 'name' => 'Laptops', + 'created_by' => $owner->id, + 'is_public' => 0, + ]); + + $privateFilterB = PredefinedFilter::factory()->create([ + 'name' => 'Coffee mugs', + 'created_by' => $owner->id, + 'is_public' => 0, + ]); + + $this->linkGroupFilter($publicFilterA, $grant); + $this->linkGroupFilter($publicFilterB, $grant); + + $response = $this->actingAs($owner, 'api') + ->getJson('/api/v1/predefinedFilters/selectlist?search=PRIVATE:&page=1'); + + $response->assertOk() + ->assertJsonFragment(['id' => $privateFilterA->id, 'text' => $privateFilterA->name . " (Private)"]) + ->assertJsonFragment(['id' => $privateFilterB->id, 'text' => $privateFilterB->name . " (Private)"]); + + $this->assertCount(2, $response->json('results')); + } + + public function testSelectlistPublic() + { + $owner = User::factory()->create(); + $grant = $this->grant($owner, ['predefinedFilter.view' => '1']); + + $publicFilterA = PredefinedFilter::factory()->create([ + 'name' => 'All coffee machines', + 'created_by' => $owner->id, + 'is_public' => 1, + ]); + + $publicFilterB = PredefinedFilter::factory()->create([ + 'name' => 'Desktops', + 'created_by' => $owner->id, + 'is_public' => 1, + ]); + + $privateFilterA = PredefinedFilter::factory()->create([ + 'name' => 'Laptops', + 'created_by' => $owner->id, + 'is_public' => 0, + ]); + + $privateFilterB = PredefinedFilter::factory()->create([ + 'name' => 'Coffee mugs', + 'created_by' => $owner->id, + 'is_public' => 0, + ]); + + $this->linkGroupFilter($publicFilterA, $grant); + $this->linkGroupFilter($publicFilterB, $grant); + + $response = $this->actingAs($owner, 'api') + ->getJson('/api/v1/predefinedFilters/selectlist?search=PUBLIC:&page=1'); + + $response->assertOk() + ->assertJsonFragment(['id' => $publicFilterA->id, 'text' => $publicFilterA->name . " (Public)"]) + ->assertJsonFragment(['id' => $publicFilterB->id, 'text' => $publicFilterB->name . " (Public)"]); + + $this->assertCount(2, $response->json('results')); + } + + public function testSelectlistPrivateSearch() + { + $owner = User::factory()->create(); + $grant = $this->grant($owner, ['predefinedFilter.view' => '1']); + + $publicFilterA = PredefinedFilter::factory()->create([ + 'name' => 'All coffee machines', + 'created_by' => $owner->id, + 'is_public' => 1, + ]); + + $publicFilterB = PredefinedFilter::factory()->create([ + 'name' => 'Desktops', + 'created_by' => $owner->id, + 'is_public' => 1, + ]); + + $privateFilterA = PredefinedFilter::factory()->create([ + 'name' => 'Laptops', + 'created_by' => $owner->id, + 'is_public' => 0, + ]); + + $privateFilterB = PredefinedFilter::factory()->create([ + 'name' => 'Coffee mugs', + 'created_by' => $owner->id, + 'is_public' => 0, + ]); + + $this->linkGroupFilter($publicFilterA, $grant); + $this->linkGroupFilter($publicFilterB, $grant); + + $response = $this->actingAs($owner, 'api') + ->getJson('/api/v1/predefinedFilters/selectlist?search=PRIVATE: Laptop&page=1'); + + $response->assertOk() + ->assertJsonFragment(['id' => $privateFilterA->id, 'text' => $privateFilterA->name . " (Private)"]); + + $this->assertCount(1, $response->json('results')); + } + + public function testSelectlistPublicSearch() + { + $owner = User::factory()->create(); + $grant = $this->grant($owner, ['predefinedFilter.view' => '1']); + + $publicFilterA = PredefinedFilter::factory()->create([ + 'name' => 'All coffee machines', + 'created_by' => $owner->id, + 'is_public' => 1, + ]); + + $publicFilterB = PredefinedFilter::factory()->create([ + 'name' => 'Desktops', + 'created_by' => $owner->id, + 'is_public' => 1, + ]); + + $privateFilterA = PredefinedFilter::factory()->create([ + 'name' => 'Laptops', + 'created_by' => $owner->id, + 'is_public' => 0, + ]); + + $privateFilterB = PredefinedFilter::factory()->create([ + 'name' => 'Coffee mugs', + 'created_by' => $owner->id, + 'is_public' => 0, + ]); + + $this->linkGroupFilter($publicFilterA, $grant); + $this->linkGroupFilter($publicFilterB, $grant); + + $response = $this->actingAs($owner, 'api') + ->getJson('/api/v1/predefinedFilters/selectlist?search=PUBLIC: coffee&page=1'); + + $response->assertOk() + ->assertJsonFragment(['id' => $publicFilterA->id, 'text' => $publicFilterA->name . " (Public)"]); + + $this->assertCount(1, $response->json('results')); + } + +} \ No newline at end of file diff --git a/tests/Feature/PredefinedFilter/UI/IndexPredefinedFiltersTest.php b/tests/Feature/PredefinedFilter/UI/IndexPredefinedFiltersTest.php new file mode 100644 index 000000000000..22c372246cc6 --- /dev/null +++ b/tests/Feature/PredefinedFilter/UI/IndexPredefinedFiltersTest.php @@ -0,0 +1,25 @@ +actingAs(User::factory()->superuser()->create()) + ->get(route('predefined-filters.index')) + ->assertOk(); + } + + public function testPredefinedFiltersPageReturns403ForUnauthorizedUser() + { + $user = User::factory()->create(); // No permissions + + $this->actingAs($user) + ->get(route('predefined-filters.index')) + ->assertForbidden(); + } +} diff --git a/tests/Feature/PredefinedFilter/UI/PredefinedFilterModalTest.php b/tests/Feature/PredefinedFilter/UI/PredefinedFilterModalTest.php new file mode 100644 index 000000000000..9f136cbeeca6 --- /dev/null +++ b/tests/Feature/PredefinedFilter/UI/PredefinedFilterModalTest.php @@ -0,0 +1,160 @@ +shouldIgnoreMissing(); + + foreach ($overrides as $method => $return) { + $mock->shouldReceive($method)->andReturn($return); + } + + $this->app->instance(PredefinedFilterService::class, $mock); + + return $mock; + } + + protected function loginUser() + { + $user = User::factory()->create(); + + $this->be($user); + + return $user; + } + + public function testOpenModalForCreationWithAllParameters() + { + $this->loginUser(); + $this->makeServiceMock(); + + $filterData = ['foo' => 'bar']; + + Livewire::test(Modal::class) + ->dispatch('openPredefinedFiltersModal', + app(PredefinedFilterService::class), + 'create', + $filterData, + 999 // even though not used for create, we pass it to ensure it is set + ) + ->assertSet('showModal', true) + ->assertSet('modalActionType', AdvancedsearchModalAction::Create) + ->assertSet('filterData', $filterData) + ->assertSet('filterId', 999) // Because we passed it; depending on desired behavior you may assert null instead + ->assertSee('Name') // Adjust to actual label or translation output + ->assertSee('Visibility') + ->assertSee('Select a Group') + ->assertSee('Save') + ->assertSee('Close'); + + } + + public function testOpenModalForCreationWithOnlyRequiredParameters() + { + $this->loginUser(); + $this->makeServiceMock(); + + Livewire::test(Modal::class) + ->dispatch('openPredefinedFiltersModal', + app(PredefinedFilterService::class), + 'create', // required + null, // optional filter data + null // optional id + ) + ->assertSet('showModal', true) + ->assertSet('modalActionType', AdvancedsearchModalAction::Create) + ->assertSet('filterData', null) + ->assertSet('filterId', null) + ->assertSee('Name') + ->assertSee('Visibility') + ->assertSee('Select a Group') + ->assertSee('Save') + ->assertSee('Close'); + } + + + public function testOpenModalForEditWithOnlyRequiredParameters() + { + $this->loginUser(); + + $service = $this->makeServiceMock([ + 'getFilterById' => [ + 'name' => 'Existing Filter', + 'is_public' => 0, + 'permissions' => collect([]), + ], + ]); + + // Passing null filter data and null ID puts component into Edit action but without ID: no look-up branch executed. + Livewire::test(Modal::class) + ->dispatch('openPredefinedFiltersModal', + $service, + 'edit', + null, + null + ) + ->assertSet('modalActionType', AdvancedsearchModalAction::Edit) + ->assertSet('filterId', null) + ->assertSet('name', '') // Not populated since ID was null + ->assertSee('Name') + ->assertSee('Visibility') + ->assertSee('Select a Group') + ->assertSee('edit') + ->assertSee('Close'); + } + + + public function testOpenModalForDeletionWithOnlyRequiredParameters() + { + $this->loginUser(); + $this->makeServiceMock(); + + Livewire::test(Modal::class) + ->dispatch('openPredefinedFiltersModal', + app(PredefinedFilterService::class), + 'delete', + null, + 456 + ) + ->assertSet('showModal', true) + ->assertSet('modalActionType', AdvancedsearchModalAction::Delete) + ->assertSet('filterId', 456) + ->assertSee('Delete') + ->assertSee('Close'); + } + + public function testOpenModalForDeletionWithMissingParameters() + { + $this->loginUser(); + $this->makeServiceMock(); + + Livewire::test(Modal::class) + ->dispatch('openPredefinedFiltersModal', + app(PredefinedFilterService::class), + 'delete', + null, + null + ) + ->assertSet('modalActionType', AdvancedsearchModalAction::Delete) + ->assertSet('filterId', null) + ->assertSee('Delete') + ->assertSee('Close'); + } +} diff --git a/tests/Support/GetExtendedPrefix.php b/tests/Support/GetExtendedPrefix.php new file mode 100644 index 000000000000..cb5b00c377b5 --- /dev/null +++ b/tests/Support/GetExtendedPrefix.php @@ -0,0 +1,38 @@ +environment('testing')) { + if (class_exists(\Database\Seeders\DemoSeeder::class)) { + putenv('SEED_DEMO=false'); + config(['snipeit.seed_demo' => false]); + } + } + $this->settings = Settings::initialize(); $this->beforeApplicationDestroyed(fn() => Setting::$_cache = null); } -} +} \ No newline at end of file diff --git a/tests/TestCase.php b/tests/TestCase.php index d9f848650afd..7dca47bcdfe0 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -5,6 +5,7 @@ use App\Http\Middleware\SecurityHeaders; use Illuminate\Foundation\Testing\LazilyRefreshDatabase; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; +use Log; use RuntimeException; use Tests\Support\AssertsAgainstSlackNotifications; use Tests\Support\AssertHasActionLogs; @@ -12,6 +13,9 @@ use Tests\Support\CustomTestMacros; use Tests\Support\InteractsWithAuthentication; use Tests\Support\InitializesSettings; +use Carbon\Carbon; +use Illuminate\Support\Facades\DB; + abstract class TestCase extends BaseTestCase { @@ -31,16 +35,34 @@ abstract class TestCase extends BaseTestCase protected function setUp(): void { $this->guardAgainstMissingEnv(); - + parent::setUp(); - + $this->registerCustomMacros(); - + $this->withoutMiddleware($this->globallyDisabledMiddleware); - + $this->initializeSettings(); + + config(['app.timnezone' => 'UTC']); + + // Removed @ — now handled safely + try { + date_default_timezone_set('UTC'); + } catch (\Throwable $e) { + Log::debug('Failed to set timezone: ' . $e->getMessage()); + } + + \Carbon::setLocale('en'); + + try { + \DB::statement("SET time_zone = '+00:00'"); + } catch (\Throwable $e) { + Log::debug($e); + } } + private function guardAgainstMissingEnv(): void { if (!file_exists(realpath(__DIR__ . '/../') . '/.env.testing')) { @@ -49,5 +71,4 @@ private function guardAgainstMissingEnv(): void ); } } - } diff --git a/tests/Unit/Models/AssetUnitTest.php b/tests/Unit/Models/AssetUnitTest.php new file mode 100644 index 000000000000..e850fff60edf --- /dev/null +++ b/tests/Unit/Models/AssetUnitTest.php @@ -0,0 +1,158 @@ +assertSame('007', Asset::zerofill(7, 3)); + $this->assertSame('42', Asset::zerofill(42, 2)); + + $this->assertSame('00042', Asset::zerofill(42, 5)); + $this->assertSame('5', Asset::zerofill(5, 1)); + } + + public function testAssignedTypeDetection() + { + $asset = new Asset; + $this->assertNull($asset->assignedType()); + + $asset->assigned_type = User::class; + $this->assertSame('user', $asset->assignedType()); + + $asset->assigned_type = Location::class; + $this->assertSame('location', $asset->assignedType()); + + $asset->assigned_type = Asset::class; + $this->assertSame('asset', $asset->assignedType()); + + $asset->assigned_type = 'Some\\Nonexistent\\Class'; + $this->assertSame('class', $asset->assignedType()); + + } + + public function testCheckedOutTypeHelpers() + { + $a = new Asset; + + $a->assigned_type = User::class; + $this->assertTrue($a->checkedOutToUser()); + $this->assertFalse($a->checkedOutToLocation()); + $this->assertFalse($a->checkedOutToAsset()); + + $a->assigned_type = Location::class; + $this->assertTrue($a->checkedOutToLocation()); + $this->assertFalse($a->checkedOutToUser()); + $this->assertFalse($a->checkedOutToAsset()); + + $a->assigned_type = Asset::class; + $this->assertTrue($a->checkedOutToAsset()); + $this->assertFalse($a->checkedOutToUser()); + $this->assertFalse($a->checkedOutToLocation()); + } + + public function testTargetShowRouteMapping() + { + $a = new Asset; + + $a->assigned_type = Asset::class; + $this->assertSame('hardware', $a->targetShowRoute()); + + $a->assigned_type = User::class; + $this->assertSame('users', $a->targetShowRoute()); + + $a->assigned_type = Location::class; + $this->assertSame('locations', $a->targetShowRoute()); + + $a->assigned_type = null; + $this->assertNull($a->targetShowRoute()); + } + + + public function testWarrantyExpiresAttribute() + { + $asset = new Asset; + + $asset->purchase_date = '2024-01-15'; + $asset->warranty_months = 12; + $expires = $asset->warranty_expires; + $this->assertInstanceOf(Carbon::class, $expires); + $this->assertSame('2025-01-15', $expires->format('Y-m-d')); + + } + + public function testWarrantyExpiresNegativeAttribute() + { + $asset = new Asset; + + $asset->purchase_date = '2024-01-15'; + $asset->warranty_months = -48; + $expires = $asset->warranty_expires; + $this->assertInstanceOf(Carbon::class, $expires); + $this->assertSame('2020-01-15', $expires->format('Y-m-d')); + + } + + public function testDateAndBoolMutators() + { + $a = new Asset; + + $a->next_audit_date = '2025-08-29'; + $this->assertSame('2025-08-29', $a->next_audit_date); + + $a->last_audit_date = '2025-08-30 12:34:56'; + $this->assertSame('2025-08-30 12:34:56', $a->last_audit_date); + + $a->last_checkout = '2025-06-01 08:00:00'; + $a->last_checkin = '2025-06-10 18:30:45'; + $this->assertSame('2025-06-01 08:00:00', $a->last_checkout); + $this->assertSame('2025-06-10 18:30:45', $a->last_checkin); + + $a->asset_eol_date = '2030-12-31'; + $this->assertSame('2030-12-31', $a->asset_eol_date); + + $a->requestable = 'true'; + $this->assertSame(1, $a->requestable); + $a->requestable = 'false'; + $this->assertSame(0, $a->requestable); + $a->requestable = 0; + $this->assertSame(0, $a->requestable); + $a->requestable = 1; + $this->assertSame(1, $a->requestable); + } + + public function testSetExpectedCheckinEmptyStringBecomesNull() + { + $a = new Asset; + $a->expected_checkin = ''; + $this->assertNull($a->expected_checkin); + } + + public function testCheckInvalidNextAuditDateLogic() + { + $a = new Asset; + + $a->last_audit_date = '2025-08-20 10:00:00'; + $a->next_audit_date = '2025-08-10'; + $this->assertTrue($a->checkInvalidNextAuditDate()); + + $a->last_audit_date = '2025-08-10 10:00:00'; + $a->next_audit_date = '2025-08-20'; + $this->assertFalse($a->checkInvalidNextAuditDate()); + + $a->last_audit_date = '2025-08-20 10:00:00'; + $a->next_audit_date = '2025-08-20'; + $this->assertFalse($a->checkInvalidNextAuditDate()); + + $a->last_audit_date = null; + $a->next_audit_date = '2025-08-20'; + $this->assertFalse($a->checkInvalidNextAuditDate()); + } +} diff --git a/tests/Unit/Models/PredefinedFilter/PredefinedFilterFilterAssetsTest.php b/tests/Unit/Models/PredefinedFilter/PredefinedFilterFilterAssetsTest.php new file mode 100644 index 000000000000..0ee5128a6973 --- /dev/null +++ b/tests/Unit/Models/PredefinedFilter/PredefinedFilterFilterAssetsTest.php @@ -0,0 +1,441 @@ +user = User::factory()->create(); + } + + /** @test */ + public function testItReturnsAllAssetsWhenFilterDataIsNull() + { + $a = Asset::factory()->create(); + $b = Asset::factory()->create(); + + $filter = PredefinedFilter::create([ + 'name' => 'null_filter', + 'created_by' => $this->user->id, + 'filter_data' => [], + ]); + + $query = Asset::query(); + $filter -> filterAssets($query); + $resultIds = $query->pluck('id') ; + + $this->assertTrue($resultIds->contains($a->id)); + $this->assertTrue($resultIds->contains($b->id)); + $this->assertCount(2, $resultIds); + } + + /** @test */ + public function itReturnsAllAssetsWhenFilterDataIsEmptyArray() + { + $a = Asset::factory()->create(); + $b = Asset::factory()->create(); + + $filter = PredefinedFilter::create([ + 'name' => 'empty_array_filter', + 'created_by' => $this->user->id, + 'filter_data'=> [], + ]); + + $query = Asset::query(); + $filter->filterAssets($query); + $resultIds = $query->pluck('id'); + + $this->assertTrue($resultIds->contains($a->id)); + $this->assertTrue($resultIds->contains($b->id)); + $this->assertCount(2, $resultIds); + } + + /** @test */ + public function itIgnoresEmptyStringsNullsAndEmptyArraysInFilterData() + { + $a = Asset::factory()->create(); + $b = Asset::factory()->create(); + + $filter = PredefinedFilter::create([ + 'name' => 'ignore_empty_values', + 'created_by' => $this->user->id, + 'filter_data' => [ + 'company_id' => '', + 'status_id' => null, + 'model_id' => [], + 'custome_fields' => [], + + ], + ]); + + $query = Asset::query(); + $filter->filterAssets($query); + $resultIds = $query->pluck('id'); + + $this->assertTrue($resultIds->contains($a->id)); + $this->assertTrue($resultIds->contains($b->id)); + $this->assertCount(2, $resultIds); + } + + /** @test */ + public function itIgnoresUnknownFilterKeysWithoutThrowing() + { + $a = Asset::factory()->create(); + $b = Asset::factory()->create(); + + $filter = PredefinedFilter::create([ + 'name' => 'unkown_keys', + 'created_by' => $this->user->id, + 'filter_data' => [ + 'totally_unkown_key' => 'whatever', + 'another_strange_key' => ['x', 'y'], + ], + ]); + + $query = Asset::query(); + $filter->filterAssets($query); + $resultIds = $query->pluck('id'); + + $this->assertTrue($resultIds->contains($a->id)); + $this->assertTrue($resultIds->contains($b->id)); + $this->assertCount(2, $resultIds); + } + + /** @test */ + public function itCastsFilterDataToArray() + { + $filter = PredefinedFilter::create([ + 'name' => 'cast_check', + 'created_by' => $this->user->id, + 'filter_data' => ['status_id' => [1,2,3]], + ]); + + $this->assertIsArray($filter->filter_data); + $this->assertEquals([1,2,3], $filter->filter_data['status_id']); + } + + //B + /** @test */ + public function itFiltersByCompanyIdScalar() + { + $user = User::factory()->create(); + + $company1 = \App\Models\Company::factory()->create(); + $company2 = \App\Models\Company::factory()->create(); + + $keep1 = Asset::factory()->create(['company_id' => $company1->id]); + $drop1 = Asset::factory()->create(['company_id' => $company2->id]); + + $filter = PredefinedFilter::create([ + 'name' => 'company_scalar', + 'created_by' => $user->id, + 'filter_data' => ['company_id' => $company1->id], + ]); + $q = Asset::query(); + $filter->filterAssets($q); + $ids = $q->pluck('id'); + + $this->assertTrue($ids->contains($keep1->id)); + $this->assertFalse($ids->contains($drop1->id)); + } + + /** @test */ + public function itFiltersByCompanyIdArray() + { + $user = User::factory()->create(); + + $company1 = \App\Models\Company::factory()->create(); + $company2 = \App\Models\Company::factory()->create(); + $company3 = \App\Models\Company::factory()->create(); + + $keep1 = Asset::factory()->create(['company_id' => $company1->id]); + $keep2 = Asset::factory()->create(['company_id' => $company2->id]); + $drop1 = Asset::factory()->create(['company_id' => $company3->id]); + + $filter = PredefinedFilter::create([ + 'name' => 'company_scalar', + 'created_by' => $user->id, + 'filter_data' =>['company_id' => [$company1->id, $company2->id]], + ]); + + $q = Asset::query(); + $filter->filterAssets($q); + $ids = $q->pluck('id'); + + $this->assertTrue($ids->contains($keep1->id)); + $this->assertTrue($ids->contains($keep2->id)); + $this->assertFalse($ids->contains($drop1->id)); + + } + + /** @test */ + public function itFiltersByStatusIdScalar() + { + $user = User::factory()->create(); + + $statusKeep = \App\Models\Statuslabel::factory()->create(); + $statusDrop = \App\Models\Statuslabel::factory()->create(); + + $keep = Asset::factory()->create(['status_id' => $statusKeep->id]); + $drop = Asset::factory()->create(['status_id' => $statusDrop->id]); + + $filter = PredefinedFilter::create([ + 'name' => 'status_scalar', + 'created_by' => $user->id, + 'filter_data' => ['status_id' => $statusKeep->id], + ]); + + $q = Asset::query(); + $filter->filterAssets($q); + $ids = $q->pluck('id'); + + $this->assertTrue($ids->contains($keep->id)); + $this->assertFalse($ids->contains($drop->id)); + } + + /** @test */ + public function itFiltersByStatusIdArray() + { + $user = User::factory()->create(); + + $st1 = \App\Models\Statuslabel::factory()->create(); + $st2 = \App\Models\Statuslabel::factory()->create(); + $st3 = \App\Models\Statuslabel::factory()->create(); + + $keep1 = Asset::factory()->create(['status_id' => $st1->id]); + $keep2 = Asset::factory()->create(['status_id' => $st2->id]); + $drop = Asset::factory()->create(['status_id' => $st3->id]); + + $filter = PredefinedFilter::create([ + 'name' => 'status_array', + 'created_by' => $user->id, + 'filter_data' => ['status_id' => [$st1->id, $st2->id]], + ]); + + $q = Asset::query(); + $filter->filterAssets($q); + $ids = $q->pluck('id'); + + $this->assertTrue($ids->contains($keep1->id)); + $this->assertTrue($ids->contains($keep2->id)); + $this->assertFalse($ids->contains($drop->id)); + } + /** @test */ + public function itFiltersByModelIdScalar() + { + $user = User::factory()->create(); + + $m1 = AssetModel::factory()->create(); + $m2 = AssetModel::factory()->create(); + // $m3 = AssetModel::factory()->create(); // Variable $m3 isn't used + AssetModel::factory()->create(); + + $keepScalar = Asset::factory()->create(['model_id' => $m1->id]); + $dropScalar = Asset::factory()->create(['model_id' => $m2->id]); + + $filterScalar = PredefinedFilter::create([ + 'name' => 'model_scalar', + 'created_by' => $user->id, + 'filter_data' => ['model_id' => $m1->id], + ]); + + $q1 = Asset::query(); + $filterScalar->filterAssets($q1); + $ids1 = $q1->pluck('id'); + + $this->assertTrue($ids1->contains($keepScalar->id)); + $this->assertFalse($ids1->contains($dropScalar->id)); + + + } + /** @test */ + public function itFiltersByModelIdArray(){ + + $user = User::factory()->create(); + + $m2 = AssetModel::factory()->create(); + $m3 = AssetModel::factory()->create(); + + $keepArr1 = Asset::factory()->create(['model_id' => $m2->id]); + $keepArr2 = Asset::factory()->create(['model_id' => $m3->id]); + $dropArr = Asset::factory()->create(); + + $filterArray = PredefinedFilter::create([ + 'name' => 'model_array', + 'created_by' => $user->id, + 'filter_data' => ['model_id' => [$m2->id, $m3->id]], + ]); + + $q2 = Asset::query(); + $filterArray->filterAssets($q2); + $ids2 = $q2->pluck('id'); + + $this->assertTrue($ids2->contains($keepArr1->id)); + $this->assertTrue($ids2->contains($keepArr2->id)); + $this->assertFalse($ids2->contains($dropArr->id)); + } + + /** @test */ + public function itCombinesMultipleIdFiltersWithAndLogic() + { + $user = User::factory()->create(); + + $stKeep = \App\Models\Statuslabel::factory()->create(); + $stOther = \App\Models\Statuslabel::factory()->create(); + + $company1 = \App\Models\Company::factory()->create(); + $company2 = \App\Models\Company::factory()->create(); + + $keep = Asset::factory()->create(['company_id' => $company1->id, 'status_id' => $stKeep->id]); + $dropCompany = Asset::factory()->create(['company_id' => $company2->id, 'status_id' => $stKeep->id]); + $dropStatus = Asset::factory()->create(['company_id' => $company1->id, 'status_id' => $stOther->id]); + + $filter = PredefinedFilter::create([ + 'name' => 'and_logic_company_status', + 'created_by' => $user->id, + 'filter_data' => [ + 'company_id' => $company1->id, + 'status_id' => [$stKeep->id], + ], + ]); + + $q = Asset::query(); + $filter->filterAssets($q); + $ids = $q->pluck('id'); + + $this->assertTrue($ids->contains($keep->id)); + $this->assertFalse($ids->contains($dropCompany->id)); + $this->assertFalse($ids->contains($dropStatus->id)); + } + + /** @test */ + public function itFiltersByManufacturerIdWithJoin() + { + $user = User::factory()->create(); + + $manufacturer1 = Manufacturer::factory()->create(); + $manufacturer2 = Manufacturer::factory()->create(); + + $model1 = AssetModel::factory()->create(['manufacturer_id' => $manufacturer1->id]); + $model2 = AssetModel::factory()->create(['manufacturer_id' => $manufacturer2->id]); + + $keep = Asset::factory()->create(['model_id' => $model1->id]); + $drop = Asset::factory()->create(['model_id' => $model2->id]); + + $filter = PredefinedFilter::create([ + 'name' => 'filer_by_manufaktur', + 'created_by' => $user->id, + 'filter_data' => ['manufacturer_id' => $manufacturer1->id], + ]); + + $q = Asset::query(); + $filter->filterAssets($q); + $ids = $q->pluck('assets.id'); + + $this->assertTrue($ids->contains($keep->id)); + $this->assertFalse($ids->contains($drop->id)); + } + + /** @test */ + public function itFiltersByCreatedAtDateRangeInsclusive() + { + $user = User::factory()->create(); + + $in = Asset::factory()->create(['created_at' => '2025-01-15']); + $out1 = Asset::factory()->create(['created_at' => '2024-12-31']); + $out2 = Asset::factory()->create(['created_at' => '2025-02-01']); + + $filter = PredefinedFilter::create([ + 'name' => 'filter_by_date', + 'created_by' => $user->id, + 'filter_data'=> [ + 'created_at_start' => '2025-01-01', + 'created_at_end' => '2025-01-31', + ], + ]); + + $q = Asset::query(); + $filter->filterAssets($q); + $ids = $q->pluck('id'); + + $this->assertTrue($ids->contains($in->id)); + $this->assertFalse($ids->contains($out1->id)); + $this->assertFalse($ids->contains($out2->id)); + } + + /** @test */ + public function itFiltersByNameWithLikeOperator() { + + $user = User::factory()->create(); + + $keep = Asset::factory()->create(['name' => 'Dell Latitude 7420']); + $drop = Asset::factory()->create(['name' => 'HP ProBook 450']); + + $filter = PredefinedFilter::create([ + 'name' => 'filter_by_date', + 'created_by' => $user->id, + 'filter_data' => ['name' => 'Latitude'], + ]); + + $q = Asset::query(); + $filter->filterAssets($q); + $ids = $q->pluck('id'); + + $this->assertTrue($ids->contains($keep->id)); + $this->assertFalse($ids->contains($drop->id)); + } + + /** @test */ + public function itFilterByMultipleCustomFieldsAndLogic() + { + $user = User::factory()->create(); + + $keep = Asset::factory()->create([ + 'asset_tag' => 'TAG-001', + 'serial' => 'SN-AAA', + ]); + + $drop1 = Asset::factory()->create([ + 'asset_tag' => 'TAG-001', + 'serial' => 'SN-WRONG', + ]); + + $drop2 = Asset::factory()->create([ + 'asset_tag' => 'TAG-XYZ', + 'serial' => 'SN-AAA', + ]); + + $filter = PredefinedFilter::create([ + 'name' => 'filter_by_custom:fields', + 'created_by' => $user->id, + 'filter_data' => [ + 'custom_fields' => [ + 'asset_tag' => 'TAG-001', + 'serial' => 'SN-AAA', + ], + ], + ]); + + $q = Asset::query(); + $filter->filterAssets($q); + $ids = $q->pluck('id'); + + $this->assertTrue($ids->contains($keep->id)); + $this->assertFalse($ids->contains($drop1->id)); + $this->assertFalse($ids->contains($drop2->id)); + } +} \ No newline at end of file diff --git a/webpack.mix.js b/webpack.mix.js index 22cf9b908aff..f10fe6adf957 100644 --- a/webpack.mix.js +++ b/webpack.mix.js @@ -40,45 +40,100 @@ mix .copy("./resources/assets/css/signature-pad.css", "./public/css/dist") .minify("./public/css/dist/signature-pad.css"); +/** + * Copy and minifiy the di container implementation + */ +mix + .copy("resources/assets/js/simpleDIContainer.js", "./public/js/dist") + .minify("./public/js/dist/simpleDIContainer.js"); + +/** + * Copy, minify and version the required files for the advanced search (advanced-search, floating buttons, modal) + */ +mix + .copy("./resources/assets/css/components/advancedSearch/modal.css", "./public/css/dist") + .minify("./public/css/dist/modal.css"); + +mix.combine([ + "./resources/assets/css/components/advancedSearch/advanced-search.css", + "./resources/assets/css/components/advancedSearch/filterInputs.css", + "./resources/assets/css/components/advancedSearch/floating-buttons.css", +], "./public/css/dist/advanced-search.css") + .minify("./public/css/dist/advanced-search.css"); + +// Keep advanced-search-index.css as a separate build artifact (used in some views) +mix + .copy("./resources/assets/css/components/advancedSearch/advanced-search-index.css", "./public/css/dist") + .minify("./public/css/dist/advanced-search-index.css"); + +mix + .copy("resources/assets/js/advancedSearch/floating-buttons.js", "./public/js/dist") + .minify("./public/js/dist/floating-buttons.js"); + +mix + .copy("resources/assets/js/advancedSearch/apiService.js", "./public/js/dist") + .minify("./public/js/dist/apiService.js"); + +mix + .copy("resources/assets/js/advancedSearch/filterInputs.js", "./public/js/dist") + .minify("./public/js/dist/filterInputs.js"); + +mix + .copy("resources/assets/js/advancedSearch/filterFormManager.js", "./public/js/dist") + .minify("./public/js/dist/filterFormManager.js"); + +mix + .copy("resources/assets/js/advancedSearch/filterUiController.js", "./public/js/dist") + .minify("./public/js/dist/filterUiController.js"); + +mix + .babel("resources/assets/js/advancedSearch/search-inputs.js", "./public/js/dist/search-inputs.js") + .minify("./public/js/dist/search-inputs.js"); +mix + .babel("resources/assets/js/advancedSearch/advanced-search.js", "./public/js/dist/advanced-search.js") + .minify("./public/js/dist/advanced-search.js"); +mix + .babel("resources/assets/js/advancedSearch/advanced-search-index.js", "./public/js/dist/advanced-search-index.js") + .minify("./public/js/dist/advanced-search-index.js"); /** * Copy and version select2 */ mix - .copy("./node_modules/select2/dist/js/i18n", "./public/js/select2/i18n") + .copy("./node_modules/select2/dist/js/i18n", "./public/js/select2/i18n") /** * Copy and version fontawesome */ mix - .copy("./node_modules/@fortawesome/fontawesome-free/webfonts", "./public/css/webfonts") + .copy("./node_modules/@fortawesome/fontawesome-free/webfonts", "./public/css/webfonts") /** * Copy BS tables js file */ mix - .copy( './node_modules/bootstrap-table/dist/bootstrap-table-locale-all.min.js', 'public/js/dist' ) - .copy( './node_modules/bootstrap-table/dist/locale/bootstrap-table-en-US.min.js', 'public/js/dist' ) + .copy('./node_modules/bootstrap-table/dist/bootstrap-table-locale-all.min.js', 'public/js/dist') + .copy('./node_modules/bootstrap-table/dist/locale/bootstrap-table-en-US.min.js', 'public/js/dist') /** * Copy Chart.js file (it's big, and used in only one place) */ mix - .copy('./node_modules/chart.js/dist/Chart.min.js', 'public/js/dist') + .copy('./node_modules/chart.js/dist/Chart.min.js', 'public/js/dist') // Combine main SnipeIT JS files mix .js( [ - "./resources/assets/js/snipeit.js", + "./resources/assets/js/snipeit.js", "./resources/assets/js/snipeit_modals.js", "./node_modules/canvas-confetti/dist/confetti.browser.js", - // The general direction we have been going is to pull these via require() directly - // But this runs in only one place, is only 24k, and doesn't break the sourcemaps - // (and it needs to run in 'immediate' mode, not in 'moar_scripts'), so let's just - // leave it here. It *could* be moved to confetti-js.blade.php, but I don't think - // it helps anything if we do that. + // The general direction we have been going is to pull these via require() directly + // But this runs in only one place, is only 24k, and doesn't break the sourcemaps + // (and it needs to run in 'immediate' mode, not in 'moar_scripts'), so let's just + // leave it here. It *could* be moved to confetti-js.blade.php, but I don't think + // it helps anything if we do that. ], - "./public/js/dist/all.js" + "./public/js/dist/all.js" ).sourceMaps(true, 'source-map', 'source-map').version(); @@ -91,7 +146,7 @@ mix [ "./node_modules/bootstrap-table/dist/bootstrap-table.css", "./node_modules/bootstrap-table/dist/extensions/sticky-header/bootstrap-table-sticky-header.css", - "./resources/assets/css/dragtable.css", + "./resources/assets/css/dragtable.css", ], "public/css/dist/bootstrap-table.css" ) @@ -102,23 +157,23 @@ mix */ mix .combine( - [ - "./resources/assets/js/dragtable.js", - './node_modules/bootstrap-table/dist/bootstrap-table.js', - './node_modules/bootstrap-table/dist/extensions/mobile/bootstrap-table-mobile.js', - './node_modules/bootstrap-table/dist/extensions/export/bootstrap-table-export.js', - './node_modules/bootstrap-table/dist/extensions/cookie/bootstrap-table-cookie.js', - './node_modules/bootstrap-table/dist/extensions/sticky-header/bootstrap-table-sticky-header.js', - './node_modules/bootstrap-table/dist/extensions/addrbar/bootstrap-table-addrbar.js', - './node_modules/bootstrap-table/dist/extensions/print/bootstrap-table-print.min.js', - './node_modules/bootstrap-table/dist/extensions/custom-view/bootstrap-table-custom-view.js', - './resources/assets/js/extensions/jquery.base64.js', - './node_modules/tableexport.jquery.plugin/tableExport.min.js', - './node_modules/tableexport.jquery.plugin/libs/jsPDF/jspdf.umd.min.js', - './resources/assets/js/FileSaver.min.js', - './node_modules/xlsx/dist/xlsx.core.min.js', - './node_modules/bootstrap-table/dist/extensions/sticky-header/bootstrap-table-sticky-header.js', - './node_modules/bootstrap-table/dist/extensions/toolbar/bootstrap-table-toolbar.js' - ], - 'public/js/dist/bootstrap-table.js' - ).version(); \ No newline at end of file + [ + "./resources/assets/js/dragtable.js", + './node_modules/bootstrap-table/dist/bootstrap-table.js', + './node_modules/bootstrap-table/dist/extensions/mobile/bootstrap-table-mobile.js', + './node_modules/bootstrap-table/dist/extensions/export/bootstrap-table-export.js', + './node_modules/bootstrap-table/dist/extensions/cookie/bootstrap-table-cookie.js', + './node_modules/bootstrap-table/dist/extensions/sticky-header/bootstrap-table-sticky-header.js', + './node_modules/bootstrap-table/dist/extensions/addrbar/bootstrap-table-addrbar.js', + './node_modules/bootstrap-table/dist/extensions/print/bootstrap-table-print.min.js', + './node_modules/bootstrap-table/dist/extensions/custom-view/bootstrap-table-custom-view.js', + './resources/assets/js/extensions/jquery.base64.js', + './node_modules/tableexport.jquery.plugin/tableExport.min.js', + './node_modules/tableexport.jquery.plugin/libs/jsPDF/jspdf.umd.min.js', + './resources/assets/js/FileSaver.min.js', + './node_modules/xlsx/dist/xlsx.core.min.js', + './node_modules/bootstrap-table/dist/extensions/sticky-header/bootstrap-table-sticky-header.js', + './node_modules/bootstrap-table/dist/extensions/toolbar/bootstrap-table-toolbar.js' + ], + 'public/js/dist/bootstrap-table.js' + ).version(); \ No newline at end of file