Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Core/Lib/ExtendedController/BaseView.php
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ public function __construct(string $name, string $title, string $modelName, stri
'customized' => false,
'itemLimit' => Tools::settings('default', 'item_limit', 50),
'megasearch' => false,
'navigation' => true,
'saveFilters' => false,
];
$this->template = static::DEFAULT_TEMPLATE;
Expand Down
11 changes: 11 additions & 0 deletions Core/Lib/ExtendedController/ListController.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,17 @@ public function privateCore(&$response, $user, $permissions)
$this->pipeFalse('loadData', $viewName, $view);
}

// Save the navigation snapshot of every view, except on actions without page render.
// The active view is extended with one page before and one after; the others only keep
// their loaded cursor, because their tabs are rendered on the same page.
if (false === in_array($action, ['export', 'megasearch'])) {
foreach ($this->views as $viewName => $view) {
if ($view instanceof ListView) {
$view->saveNavigation($viewName === $this->active);
}
}
}

// Execute actions after loading data
$this->execAfterAction($action);
$this->pipeFalse('execAfterAction', $action);
Expand Down
131 changes: 131 additions & 0 deletions Core/Lib/ExtendedController/ListView.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,16 @@

namespace FacturaScripts\Core\Lib\ExtendedController;

use FacturaScripts\Core\Base\DataBase;
use FacturaScripts\Core\Base\DataBase\DataBaseWhere;
use FacturaScripts\Core\Cache;
use FacturaScripts\Core\Model\Base\BusinessDocument;
use FacturaScripts\Core\Model\Base\ModelClass as LegacyModelClass;
use FacturaScripts\Core\Request;
use FacturaScripts\Core\Session;
use FacturaScripts\Core\Template\ModelClass;
use FacturaScripts\Core\Tools;
use FacturaScripts\Core\Where;
use FacturaScripts\Dinamic\Lib\AssetManager;
use FacturaScripts\Dinamic\Lib\ExportManager;
use FacturaScripts\Dinamic\Lib\Widget\ColumnItem;
Expand All @@ -44,6 +47,16 @@ class ListView extends BaseView

const DEFAULT_TEMPLATE = 'Master/ListView.html.twig';

/** Number of tokens for view and user. Used for edit navigator */
const NAVIGATION_TOKENS = 10;

/**
* Token for actual render.
*
* @var string
*/
public $navToken = '';

/** @var string */
public $orderKey = '';

Expand Down Expand Up @@ -288,6 +301,52 @@ public function processFormData($request, $case)
}
}

/**
* Guarda en caché la foto de los códigos visibles, identificada por un token
* nuevo que las filas añaden a sus enlaces para poder navegar entre registros
* desde el EditController de destino.
*
* @param bool $extended (true = amplía la foto una página antes y otra después)
*/
public function saveNavigation(bool $extended = false): void
{
// only when the row link opens the record itself: if the code in the link is not
// the primary key (document lines, line joins), there is nothing to navigate.
if (empty($this->settings['navigation'])
|| empty($this->cursor)
|| false === $this->rowCodeIsPrimaryKey()
) {
return;
}

$start = $this->offset;
$codes = $this->navigationCodes($start, $extended);
if (empty($codes)) {
return;
}

// save seed with new token
$nick = Session::user()->nick;
$this->navToken = bin2hex(random_bytes(8));
Cache::set('nav-' . $nick . '-' . $this->navToken, [
'codes' => $codes,
'count' => $this->count,
'start' => $start,
]);

// only keep the latest tokens from this view and delete expired seeds
$tokensKey = 'nav-tokens-' . Session::get('controllerName') . '-' . $this->getViewName() . '-' . $nick;
$tokens = Cache::get($tokensKey);
if (false === is_array($tokens)) {
$tokens = [];
}
$tokens[] = $this->navToken;
while (count($tokens) > self::NAVIGATION_TOKENS) {
Cache::delete('nav-' . $nick . '-' . array_shift($tokens));
}
Cache::set($tokensKey, $tokens);
}

/**
* Adds assets to the asset manager.
*/
Expand Down Expand Up @@ -426,4 +485,76 @@ protected function setSelectedOrderBy(string $orderKey): void
$this->orderKey = $orderKey;
}
}

/**
* Returns the codes for the navigation seed. By default, these are the codes for the
* already loaded cursor (no cost). In extended mode and with a direct model,
* one page before and one after are extended by querying only the
* primary key, adjusting $start to the actual start of the window.
*
* @param int &$start
* @param bool $extended
* @return array
*/
private function navigationCodes(int &$start, bool $extended): array
{
// Standard Model and Older Model
$direct = $this->model instanceof ModelClass || $this->model instanceof LegacyModelClass;
if ($extended && $direct) {
$sql = 'SELECT ' . $this->model->primaryColumn()
. ' FROM ' . $this->model->tableName()
. Where::multiSqlLegacy($this->where);

if ($this->order) {
$orderBy = [];
foreach ($this->order as $field => $direction) {
$orderBy[] = $field . ' ' . $direction;
}
$sql .= ' ORDER BY ' . implode(', ', $orderBy);
}

// have 3 record pages (previous - actual - next)
$limit = 3 * $this->settings['itemLimit'];
$start = max(0, $this->offset - $this->settings['itemLimit']);

$codes = [];
$db = new DataBase();
foreach ($db->selectLimit($sql, $limit, $start) as $row) {
$codes[] = $row[$this->model->primaryColumn()];
}
return $codes;
}

// Other cases: the primary key of each record on the visible page
$codes = [];
foreach ($this->cursor as $model) {
$code = method_exists($model, 'id')
? $model->id()
: $model->primaryColumnValue();

if (null === $code || '' === $code) {
return [];
}
$codes[] = $code;
}
return $codes;
}

/**
* Returns true when the row link opens the record itself: the code in the link
* matches the primary key of the model, checked on the first loaded record.
* False when url() is overridden to open another model (document lines, line joins).
*/
private function rowCodeIsPrimaryKey(): bool
{
$model = reset($this->cursor);
parse_str(parse_url($model->url(), PHP_URL_QUERY) ?: '', $params);
$code = $params['code'] ?? '';
if ('' === $code || false === is_string($code)) {
return false;
}

$pk = method_exists($model, 'id') ? $model->id() : $model->primaryColumnValue();
return (string)$pk === $code;
}
}
44 changes: 44 additions & 0 deletions Core/Lib/ExtendedController/PanelController.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
namespace FacturaScripts\Core\Lib\ExtendedController;

use FacturaScripts\Core\Base\ControllerPermissions;
use FacturaScripts\Core\Cache;
use FacturaScripts\Core\Response;
use FacturaScripts\Core\Tools;
use FacturaScripts\Dinamic\Model\User;
Expand Down Expand Up @@ -63,6 +64,44 @@ public function getImageUrl(): string
return '';
}

/**
* Returns the data to navigate between the records in the source list.
* - Array: When accessed from a row link with a navigation token.
* - Empty: When no token, the seed has expired or the current record is not in it.
*
* @return array
*/
public function getNavigation(): array
{
$token = $this->request->query->getAlnum('navfrom');
if (empty($token)) {
return [];
}

// search the seed of code list for user.
$data = Cache::get('nav-' . $this->user->nick . '-' . $token);
if (false === is_array($data) || empty($data['codes'])) {
return [];
}

// locate the record into the seed
$model = $this->tab($this->getMainViewName())->model;
$code = method_exists($model, 'id') ? $model->id() : $model->primaryColumnValue();
$codes = array_map('strval', $data['codes']);
$position = array_search((string)$code, $codes, true);
if (false === $position) {
return [];
}

$url = $this->url() . '?code=%s&navfrom=' . $token;
return [
'count' => $data['count'] ?? count($codes),
'next' => isset($codes[$position + 1]) ? sprintf($url, rawurlencode($codes[$position + 1])) : '',
'position' => ($data['start'] ?? 0) + $position + 1,
'prev' => $position > 0 ? sprintf($url, rawurlencode($codes[$position - 1])) : '',
];
}

/**
* Runs the controller's private logic.
*
Expand Down Expand Up @@ -105,6 +144,11 @@ public function privateCore(&$response, $user, $permissions)
if ($viewName === $mainViewName && $view->model->exists()) {
$this->hasData = true;
}

// save the navigation snapshot of list tabs, except on actions without page render
if ('export' !== $action && $view instanceof ListView) {
$view->saveNavigation();
}
}

// General operations with the loaded data
Expand Down
8 changes: 6 additions & 2 deletions Core/View/Master/ListView.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,11 @@
{% for model in currentView.cursor %}
{% set trClass = currentView.settings.clickable ? 'clickableListRow ' ~ rowStatus.trClass(model) : rowStatus.trClass(model) %}
{% set trTitle = rowStatus.trTitle(model) %}
<tr class="{{ trClass }}" title="{{ trTitle }}" data-href="{{ asset(model.url()) }}">
{% set rowUrl = model.url() %}
{% if rowUrl and currentView.navToken %}
{% set rowUrl = rowUrl ~ ('?' in rowUrl ? '&' : '?') ~ 'navfrom=' ~ currentView.navToken %}
{% endif %}
<tr class="{{ trClass }}" title="{{ trTitle }}" data-href="{{ asset(rowUrl) }}">
{% if currentView.settings.checkBoxes or currentView.settings.clickable %}
<td class="cancelClickable p-0 text-center align-middle">
{% if currentView.settings.checkBoxes %}
Expand All @@ -162,7 +166,7 @@
</div>
{% endif %}
{% if currentView.settings.clickable %}
<a href="{{ asset(model.url()) }}" target="_blank" class="toggle-ext-link d-none"
<a href="{{ asset(rowUrl) }}" target="_blank" class="toggle-ext-link d-none"
onauxclick="$(this).addClass('text-dark');" title="{{ trans('open-tab') }}">
<i class="fa-solid fa-external-link-alt"></i>
</a>
Expand Down
29 changes: 28 additions & 1 deletion Core/View/Master/PanelController.html.twig
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{#
/**
* This file is part of FacturaScripts
* Copyright (C) 2017-2025 Carlos Garcia Gomez <carlos@facturascripts.com>
* Copyright (C) 2017-2026 Carlos Garcia Gomez <carlos@facturascripts.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as
Expand Down Expand Up @@ -60,6 +60,33 @@
<i class="fa-solid fa-redo" aria-hidden="true"></i>
</a>
</div>
{# -- Navigation buttons -- #}
{% set navData = fsc.getNavigation() %}
{% if navData %}
<div class="btn-group">
{% if navData.prev %}
<a href="{{ navData.prev }}" class="btn btn-sm btn-secondary" title="{{ trans('previous') }}">
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
</a>
{% else %}
<button type="button" class="btn btn-sm btn-secondary" disabled>
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
</button>
{% endif %}
<button type="button" class="btn btn-sm btn-secondary" disabled>
{{ navData.position }} / {{ navData.count }}
</button>
{% if navData.next %}
<a href="{{ navData.next }}" class="btn btn-sm btn-secondary" title="{{ trans('next') }}">
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
</a>
{% else %}
<button type="button" class="btn btn-sm btn-secondary" disabled>
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
</button>
{% endif %}
</div>
{% endif %}
{# -- Options button -- #}
{{ _self.optionsButton(fsc, firstView) }}
{# -- New button -- #}
Expand Down
29 changes: 28 additions & 1 deletion Core/View/Master/PanelControllerTop.html.twig
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{#
/**
* This file is part of FacturaScripts
* Copyright (C) 2017-2025 Carlos Garcia Gomez <carlos@facturascripts.com>
* Copyright (C) 2017-2026 Carlos Garcia Gomez <carlos@facturascripts.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as
Expand Down Expand Up @@ -64,6 +64,33 @@
<i class="fa-solid fa-redo" aria-hidden="true"></i>
</a>
</div>
{# -- Navigation buttons -- #}
{% set navData = fsc.getNavigation() %}
{% if navData %}
<div class="btn-group">
{% if navData.prev %}
<a href="{{ navData.prev }}" class="btn btn-sm btn-secondary" title="{{ trans('previous') }}">
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
</a>
{% else %}
<button type="button" class="btn btn-sm btn-secondary" disabled>
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
</button>
{% endif %}
<button type="button" class="btn btn-sm btn-secondary" disabled>
{{ navData.position }} / {{ navData.count }}
</button>
{% if navData.next %}
<a href="{{ navData.next }}" class="btn btn-sm btn-secondary" title="{{ trans('next') }}">
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
</a>
{% else %}
<button type="button" class="btn btn-sm btn-secondary" disabled>
<i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
</button>
{% endif %}
</div>
{% endif %}
{# -- Options button -- #}
{{ _self.optionsButton(fsc, firstView, i18n) }}
{# -- New button -- #}
Expand Down
4 changes: 4 additions & 0 deletions Core/View/Tab/AccountingEntry.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@
data.messages.forEach(item => alert(item.message));
}
if (data.ok) {
const navfrom = new URLSearchParams(window.location.search).get('navfrom');
if (navfrom) {
data.newurl += (data.newurl.includes('?') ? '&' : '?') + 'navfrom=' + encodeURIComponent(navfrom);
}
window.location.replace(data.newurl);
} else {
animateSpinner('remove', true);
Expand Down
4 changes: 4 additions & 0 deletions Core/View/Tab/PurchasesDocument.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,10 @@
data.messages.forEach(item => alert(item.message));
}
if (data.ok) {
const navfrom = new URLSearchParams(window.location.search).get('navfrom');
if (navfrom) {
data.newurl += (data.newurl.includes('?') ? '&' : '?') + 'navfrom=' + encodeURIComponent(navfrom);
}
window.location.replace(data.newurl);
} else {
animateSpinner('remove', true);
Expand Down
4 changes: 4 additions & 0 deletions Core/View/Tab/SalesDocument.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,10 @@
data.messages.forEach(item => alert(item.message));
}
if (data.ok) {
const navfrom = new URLSearchParams(window.location.search).get('navfrom');
if (navfrom) {
data.newurl += (data.newurl.includes('?') ? '&' : '?') + 'navfrom=' + encodeURIComponent(navfrom);
}
window.location.replace(data.newurl);
} else {
animateSpinner('remove', true);
Expand Down
Loading