New filter option

This commit is contained in:
Sander Dorigo committed 2026-09-29 14:33:48 +02:00
1 parent 57af6c76f5
commit 90f80dc7b1
18 files changed
+160 -36

No files matched your search

@@ -75,14 +75,16 @@ final class ShowController extends Controller
'limit' => $limit,
'offset' => $offset,
'sort' => $sort,
'filter' => $filter,
'start' => $start,
'end' => $end,
'active' => $active,
'date' => $date,
] = $request->attributes->all();
// get list of accounts. Count it and split it.
$this->repository->resetAccountOrder();
$collection = $this->repository->getAccountsByType($types, $sort, $active);
$collection = $this->repository->getAccountsByType($types, $sort, $filter, $active);
$count = $collection->count();
$dbFields = $this->isAllDatabaseSort($sort, 'Account');
$accounts = $collection;
+4 -3
View File
@@ -26,6 +26,7 @@ namespace FireflyIII\Api\V1\Requests;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Override;
use RuntimeException;
@@ -52,7 +53,7 @@ abstract class AggregateFormRequest extends ApiRequest
parent::initialize($query, $request, $attributes, $cookies, $files, $server, $content);
// instantiate all subrequests and share current requests' bags with them
// Log::debug('Initializing AggregateFormRequest.');
Log::debug('Initializing AggregateFormRequest.');
/** @var array|string $config */
foreach ($this->getRequests() as $config) {
@@ -61,7 +62,7 @@ abstract class AggregateFormRequest extends ApiRequest
if (!is_a($requestClass, Request::class, true)) {
throw new RuntimeException('getRequests() must return class-strings of subclasses of Request');
}
// Log::debug(sprintf('Initializing subrequest %s', $requestClass));
Log::debug(sprintf('Initializing subrequest %s', $requestClass));
$instance = new $requestClass();
$this->requests[] = $instance;
@@ -96,7 +97,7 @@ abstract class AggregateFormRequest extends ApiRequest
// register all subrequests' validators
foreach ($this->requests as $request) {
if (method_exists($request, 'withValidator')) {
// Log::debug(sprintf('Process withValidator from class %s', $request::class));
Log::debug(sprintf('Process withValidator from class %s', $request::class));
$request->withValidator($validator);
}
}
+16 -6
View File
@@ -45,14 +45,15 @@ class FilterRequest extends ApiRequest
$this->filterClass = $config['filter_class'] ?? null;
if (null === $this->filterClass) {
throw new RuntimeException('FilterRequest requires a filter_class config');
//throw new RuntimeException('FilterRequest requires a filter_class config');
$this->filterClass = 'Account';
}
}
public function rules(): array
{
return [
'filter.*' => ['min:0','max:255', $this->required, new IsValidFilterInstruction((string) $this->filterClass)],
'filter' => ['min:0','max:255', $this->required, new IsValidFilterInstruction((string) $this->filterClass)],
];
}
@@ -62,14 +63,23 @@ class FilterRequest extends ApiRequest
if (count($validator->failed()) > 0) {
return;
}
var_dump($this->input('filter'));exit;
$filter = $this->convertToFilter();
});
return;
$validator->after(function (Validator $validator): void {
if (count($validator->failed()) > 0) {
return;
}
$filter = $this->convertToFilter('');
$this->attributes->set('filter', $filter);
});
}
private function convertToFilter(string $value): array {
var_dump($value);
exit;
return [];
// var_dump($value);
// exit;
// return [];
}
}
@@ -38,7 +38,6 @@ class ShowRequest extends AggregateFormRequest
{
return [
[PaginationRequest::class, 'sort_class' => Account::class],
[FilterRequest::class, 'filter_class' => Account::class],
DateRangeRequest::class,
ActiveObjectRequest::class,
DateRequest::class,
+10 -3
View File
@@ -23,6 +23,7 @@ declare(strict_types=1);
namespace FireflyIII\Api\V1\Requests;
use FireflyIII\Rules\IsValidFilterInstruction;
use FireflyIII\Rules\IsValidSortInstruction;
use FireflyIII\Support\Facades\Preferences;
use FireflyIII\User;
@@ -50,6 +51,7 @@ class PaginationRequest extends ApiRequest
{
return [
'sort' => ['nullable', new IsValidSortInstruction((string) $this->sortClass)],
'filter' => ['nullable', new IsValidFilterInstruction((string)$this->sortClass)],
'limit' => ['numeric', 'min:1', 'max:131337'],
'page' => ['numeric', 'min:1', 'max:131337'],
];
@@ -57,24 +59,29 @@ class PaginationRequest extends ApiRequest
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
if (count($validator->failed()) > 0) {
return;
}
$data = $validator->getData();
$limit = $this->integerFromValue($data['limit'] ?? null);
$limit = $this->convertInteger('limit');
if (0 === $limit) {
// get default for user:
/** @var User $user */
$user = auth()->user();
$limit = (int) Preferences::getForUser($user, 'listPageSize', 50)->data;
}
$page = $this->convertInteger('page');
$page = $this->integerFromValue($data['page'] ?? '1');
$page = clamp(value: $page, min: 1, max: 2 ** 16);
$offset = ($page - 1) * $limit;
$sort = null !== $this->sortClass ? $this->convertSortParameters('sort', $this->sortClass) : $this->get('sort');
$sort = null !== $this->sortClass ? $this->convertSortParameters('sort', $this->sortClass) : '';
$filter = null !== $this->sortClass ? $this->convertFilterParameters('filter', $this->sortClass) : [];
$this->attributes->set('limit', $limit);
$this->attributes->set('sort', $sort);
$this->attributes->set('filter', $filter);
$this->attributes->set('page', $page);
$this->attributes->set('offset', $offset);
});
+5 -1
View File
@@ -175,7 +175,11 @@ class AttachmentHelper implements AttachmentHelperInterface
$parts = explode('/', $attachment->fileName());
$file = $parts[count($parts) - 1];
Log::debug(sprintf('Write file to disk in file named "%s"', $file));
$this->uploadDisk->put($file, $content);
$res = $this->uploadDisk->put($file, $content);
if(false === $res) {
Log::error('Could not write to disk.');
return false;
}
// update attachment.
$attachment->md5 = md5_file($path);
+13 -1
View File
@@ -225,7 +225,7 @@ class AccountRepository implements AccountRepositoryInterface, UserGroupInterfac
return $query->get(['accounts.*']);
}
public function getAccountsByType(array $types, ?array $sort = [], ?bool $filterActive = null): Collection
public function getAccountsByType(array $types, ?array $sort = [], ?array $filter = [], ?bool $filterActive = null): Collection
{
$res = array_intersect([
AccountTypeEnum::ASSET->value,
@@ -244,6 +244,8 @@ class AccountRepository implements AccountRepositoryInterface, UserGroupInterfac
// add sort parameters
$allowed = config('firefly.allowed_db_sort_parameters.Account', []);
$sorted = 0;
$sort = $sort ?? [];
$filter = $filter ?? [];
if (0 !== count($sort)) {
foreach ($sort as $param) {
if (in_array($param[0], $allowed, true)) {
@@ -252,6 +254,16 @@ class AccountRepository implements AccountRepositoryInterface, UserGroupInterfac
}
}
}
$allowed = config('firefly.allowed_filter_parameters.Account', []);
if (0 !== count($filter)) {
foreach ($filter as $field => $search) {
if (in_array($field, $allowed, true)) {
if('' !== (string) $search) {
$query->whereLike($field, sprintf('%%%s%%', $search));
}
}
}
}
if (0 === $sorted) {
if (0 !== count($res)) {
@@ -90,7 +90,7 @@ interface AccountRepositoryInterface
*
* @deprecated
*/
public function getAccountsByType(array $types, ?array $sort = [], ?bool $filterActive = null): Collection;
public function getAccountsByType(array $types, ?array $sort = [], ?array $filter = [], ?bool $filterActive = null): Collection;
public function getAccountsByTypeForGroup(array $types, ?array $sort = []): Collection;
+22 -4
View File
@@ -33,11 +33,29 @@ class IsValidFilterInstruction implements ValidationRule
#[\Override]
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if('' === (string)$value) {
if(!is_array($value)) {
return;
}
var_dump($attribute);
var_dump($value);
// TODO: Implement validate() method.
if(0 === count($value)) {
return;
}
$shortClass = str_replace('FireflyIII\Models\\', '', $this->class);
$validParameters = config(sprintf('firefly.allowed_filter_parameters.%s', $shortClass));
if (!is_array($validParameters)) {
$fail('validation.no_filter_instructions')->translate(['object' => $shortClass]);
return;
}
foreach($value as $key => $search) {
if(!in_array($key, $validParameters)) {
$fail('validation.no_filter_instructions')->translate(['object' => $shortClass]);
return;
}
if(strlen($search) > 50) {
$fail('validation.no_filter_instructions')->translate(['object' => $shortClass]);
return;
}
}
return;
}
}
+21
View File
@@ -186,6 +186,27 @@ trait ConvertsDataTypes
return $sortParameters;
}
public function convertFilterParameters(string $field, string $class): array
{
$parameter = request()->query->all($field);
if(!is_array($parameter)) {
return [];
}
if(0 === count($parameter)) {
return [];
}
$shortClass = str_replace('FireflyIII\Models\\', '', $class);
$validParameters = config(sprintf('firefly.allowed_filter_parameters.%s', $shortClass));
$return = [];
foreach($parameter as $key => $search) {
if(in_array($key, $validParameters) && strlen($search) <= 50) {
$return[$key] = trim($search);
}
}
return $return;
}
/**
* Return string value.
*/
+5 -1
View File
@@ -848,7 +848,11 @@ return [
// dynamic date ranges are as follows:
'dynamic_date_ranges' => ['last7', 'last30', 'last90', 'last365', 'MTD', 'QTD', 'YTD'],
'allowed_filter_parameters' => [
'Account' => [
'name'
],
],
'allowed_sort_parameters' => [
'Account' => [
'id',
+33 -9
View File
@@ -25,7 +25,7 @@ import Alpine from "alpinejs";
import {getVariable} from "../../store/get-variable.js";
import Put from "../../api/model/account/put.js";
import Get from "../../api/model/account/get.js";
import {format} from "date-fns";
import format from '../../util/format.js';
import formatMoney from "../../util/format-money.js";
import i18next from "i18next";
import {addDrag} from "../shared/drag-and-droppable-rows.js";
@@ -52,21 +52,42 @@ let index = function () {
sums: {},
debts: {},
differences: {},
filter: {
name: 'chec',
},
formatMoney: formatMoney,
objectToQueryString(obj, prefix) {
return Object.keys(obj).map(objKey => {
if (obj.hasOwnProperty(objKey)) {
const key = prefix ? `${prefix}[${objKey}]` : objKey;
const value = obj[objKey];
return typeof value === "object" ?
this.objectToQueryString(value, key) :
`${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
}
return null;
}).join("&");
},
updateHistory() {
if (history.pushState) {
// TODO fix this.
let obj = {
page: this.page,
column: this.sortColumn,
direction: this.sortDirection,
filter: this.filter
};
let string = new URLSearchParams(obj).toString();
string = this.objectToQueryString(obj);
let newUrl =
window.location.protocol +
"//" +
window.location.host +
window.location.pathname +
"?page=" +
this.page +
"&column=" +
this.sortColumn +
"&direction=" +
this.sortDirection;
"?" +string;
window.history.pushState({path: newUrl}, "", newUrl);
window.store.set(this.storageKey, {column: this.sortColumn, direction: this.sortDirection});
}
@@ -135,7 +156,9 @@ let index = function () {
let input = document.querySelector(`div.search-filter[data-column="${column}"]`);
input.classList.add('d-none');
// TODO also clear the search filter!!!!
// TODO hardcoded!!
this.filter.name = '';
});
});
@@ -191,6 +214,7 @@ let index = function () {
page: this.page,
type: this.objectType,
start: start,
filter: this.filter,
end: end,
})
.then((response) => {
@@ -330,7 +354,7 @@ let index = function () {
if (null === date) {
return "";
}
return format(new Date(date), i18next.t("config.date_time_fns_short", {lng: window.store.get("locale")}));
return format(new Date(date), i18next.t("config.date_time_fns_short", {lng: window.store.get("locale")}), window.store.get("locale"));
},
handlePageClick(e) {
let link = e.currentTarget;
@@ -336,7 +336,7 @@ let create = function () {
this.processUploadError(event);
});
document.addEventListener("upload-failed", (event) => {
// console.log('Now in event listener "upload-failed"');
console.log('Now in event listener "upload-failed"');
this.processUploadError(event);
});
},
@@ -344,6 +344,11 @@ let transactions = function () {
this.autoStep();
});
document.addEventListener("upload-failed", (event) => {
console.log('Now in event listener "upload-failed"');
this.processUploadError(event);
});
// add some event listeners
document.addEventListener("upload-success", () => {
// console.log('Trigger on event "upload-success"');
+7 -3
View File
@@ -95,8 +95,12 @@ export default function (date, formatStr = "PP", locale) {
if (typeof locale === "undefined") {
locale = window.__localeId__.replace("_", "");
}
let res = '(DATE ERROR)';
try {
res = format(date, formatStr, {locale: locales[locale] ?? locales[locale.slice(0, 2)] ?? locales["enUS"],});
} catch(error) {
console.warn('Could not format date: ', error);
}
// console.log("Locale is " + locale);
return format(date, formatStr, {
locale: locales[locale] ?? locales[locale.slice(0, 2)] ?? locales["enUS"], // or global.__localeId__
});
return res;
}
+12
View File
@@ -0,0 +1,12 @@
export default function serialize (obj, prefix) {
let str = [], p;
for (p in obj) {
if (Object.hasOwn(obj, p)) {
let k = prefix ? prefix + "[" + p + "]" : p, v = obj[p];
str.push((v !== null && typeof v === "object") ? serialize(v, k) : encodeURIComponent(k) + "=" + encodeURIComponent(v));
}
}
return str.join("&");
}
+1
View File
@@ -31,6 +31,7 @@ return [
'invalid_transaction_type_list' => 'Invalid transaction type list',
'limit_exists' => 'There is already a budget limit (amount) for this budget and currency in the given period.',
'invalid_sort_instruction' => 'The sort instruction is invalid for an object of type ":object".',
'no_filter_instructions' => 'The filter instruction is invalid for an object of type ":object".',
'invalid_sort_instruction_index' => 'The sort instruction at index #:index is invalid for an object of type ":object".',
'no_sort_instructions' => 'There are no sort instructions defined for an object of type ":object".',
'webhook_budget_info' => 'Cannot deliver budget information for transaction related webhooks.',
+1 -1
View File
@@ -35,7 +35,7 @@
<span data-column="name" class="title">{{ trans('list.name') }}</span>
<span data-column="name" class="search-spacer">&nbsp;</span><em data-column="name" class="search-button search-button-name bi bi-search"></em>
<div data-column="name" class="search-filter search-filter-name d-none input-group">
<input type="search" class="form-control form-control-sm" placeholder="Filter name...">
<input type="search" x-model="filter.name" class="form-control form-control-sm" placeholder="Filter name...">
<button class="btn btn-outline-secondary btn-sm hide-button" data-column="name" type="button"><em class="bi bi-x text-danger"></em></button>
</div>
</th>