Skip to content
Merged
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
12 changes: 4 additions & 8 deletions app/Core/UI/Template.php
Original file line number Diff line number Diff line change
Expand Up @@ -676,20 +676,16 @@ public function setNotification(string $msg, string $type, string $event_id = ''
}

/**
* getToggleState - retrieves the toggle state of a submenu by name from the session
* getToggleState - a saved UI state of the current user (accordion / submenu open or closed …)
*
* @param string $name - the name of the submenu toggle
* @return string|false - the toggle state of the submenu ("true"/"false"), or false if unset
* @param string $name - the name of the state
* @return string|false - the saved value ("open", "closed" …), or false if never saved
*
* @deprecated this should be in a component
*/
public function getToggleState(string $name): string|false
{
if (session()->exists('usersettings.submenuToggle.'.$name)) {
return session('usersettings.submenuToggle.'.$name);
}

return false;
return app(UiState::class)->get($name) ?? false;
}

/**
Expand Down
186 changes: 186 additions & 0 deletions app/Core/UI/UiState.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
<?php

namespace Leantime\Core\UI;

use Illuminate\Database\ConnectionInterface;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
use Leantime\Core\Db\Db as DbCore;

/**
* Per-user UI state: which accordions are open, sidebar widths, a calendar's last view …
*
* Each state is its own row in zp_settings ("usersetting.{userId}.ui.{name}"), written with a
* single atomic upsert. Nothing goes through the session, so parallel requests can't overwrite
* each other's states and a saved state shows on the very next page load (JSON-RPC requests,
* which save most states, never persist the session).
*
* Reads load all of the user's states once per request (one query). States saved before this
* store existed live in the serialized "usersetting.{userId}.submenuToggle" setting; they are
* read as a fallback, so earlier choices carry over.
*/
class UiState
{
/** zp_settings.key is VARCHAR(175). */
private const MAX_KEY_LENGTH = 175;

/** @var array<string, string>|null The loaded user's states, keyed by stored name. */
private ?array $states = null;

private ?int $loadedFor = null;

/**
* @param ConnectionInterface|null $db The connection to use; resolved on first use when null
* (connecting can fail before install, and unit tests
* have no database).
*/
public function __construct(private ?ConnectionInterface $db = null) {}

/**
* A state of the current user, or $default when it was never saved.
*/
public function get(string $name, ?string $default = null): ?string
{
return $this->all()[$this->storedName($name)] ?? $default;
}

/**
* Save a state of the current user. One row, one statement: concurrent saves of other states
* can't drop it.
*/
public function set(string $name, string $value): void
{
$userId = $this->userId();
if ($userId === null) {
return;
}

$storedName = $this->storedName($name);
$db = $this->connection();
if ($db === null) {
return;
}
$db->table('zp_settings')->upsert(
[['key' => $this->prefix($userId).$storedName, 'value' => $value]],
['key'],
['value']
);

if ($this->loadedFor === $userId && $this->states !== null) {
$this->states[$storedName] = $value;
}
}

/**
* All states of the current user, keyed by stored name.
*
* @return array<string, string>
*/
public function all(): array
{
$userId = $this->userId();
if ($userId === null) {
return [];
}

if ($this->states !== null && $this->loadedFor === $userId) {
return $this->states;
}

$this->loadedFor = $userId;
$this->states = $this->load($userId);

return $this->states;
}

/**
* @return array<string, string>
*/
private function load(int $userId): array
{
try {
$legacyKey = 'usersetting.'.$userId.'.submenuToggle';
$prefix = $this->prefix($userId);

$db = $this->connection();
if ($db === null) {
return [];
}
$rows = $db->table('zp_settings')
->where('key', $legacyKey)
// The prefix is letters, digits and dots only: no LIKE wildcards to escape.
->orWhere('key', 'like', $prefix.'%')
->get(['key', 'value']);
} catch (\Throwable $e) {
// Before install / during an update the table may not be usable: no states then.
Log::warning('UI states could not be loaded: '.$e->getMessage());

return [];
}

$states = [];
foreach ($rows as $row) {
if ($row->key !== $legacyKey) {
continue;
}
$legacy = safe_unserialize((string) $row->value, []);
// The old store wrote through a dot-notation session path, so a name with dots
// ("section.part") was saved nested: flatten it back to the dotted name.
foreach (is_array($legacy) ? Arr::dot($legacy) : [] as $name => $value) {
if (is_scalar($value)) {
$states[$this->storedName((string) $name)] = (string) $value;
}
}
}

// Rows written by this store win over the legacy blob.
foreach ($rows as $row) {
if ($row->key !== $legacyKey && str_starts_with($row->key, $prefix)) {
$states[substr($row->key, strlen($prefix))] = (string) $row->value;
}
}

return $states;
}

private function connection(): ?ConnectionInterface
{
if ($this->db !== null) {
return $this->db;
}

try {
return $this->db = app(DbCore::class)->getConnection();
} catch (\Throwable $e) {
return null;
}
}

private function prefix(int $userId): string
{
return 'usersetting.'.$userId.'.ui.';
}

/**
* The name as stored in the key. zp_settings.key compares case- and accent-insensitively on
* MySQL (utf8mb4_unicode_ci), so "Foo" and "foo" would share a row: everything outside
* [a-z0-9_-] is escaped ("~" + hex), which keeps distinct names distinct under any collation.
* Names that would make the key too long are shortened to a stable hash.
*/
private function storedName(string $name): string
{
$encoded = preg_replace_callback('/[^a-z0-9_-]/', fn ($m) => '~'.bin2hex($m[0]), $name);

// Longest prefix for a realistic user id ("usersetting.9999999999.ui.").
$room = self::MAX_KEY_LENGTH - 26;

return strlen($encoded) <= $room ? $encoded : substr($encoded, 0, $room - 41).'~'.sha1($name);
}

private function userId(): ?int
{
$userId = session('userdata.id');

return is_numeric($userId) && (int) $userId > 0 ? (int) $userId : null;
}
}
6 changes: 6 additions & 0 deletions app/Core/UI/ViewsServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ public function register()
$this->registerBladeCompiler();
$this->registerEngineResolver();

// Per-user UI states (accordions, sidebar widths …): one instance per request so the
// states load once (UiState::all()).
// Built without a connection on purpose: it connects on first use, so pages and tests
// without a usable database still render (with default states).
$this->app->scoped(UiState::class, fn () => new UiState);

$this->app->terminating(static function () {
\Illuminate\View\Component::flushCache();
});
Expand Down
15 changes: 7 additions & 8 deletions app/Domain/Calendar/Templates/showMyCalendar.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@
@section('content')

@php
if (! session()->exists('usersettings.submenuToggle.myCalendarView')) {
session(['usersettings.submenuToggle.myCalendarView' => 'dayGridMonth']);
}
// The calendar's last view (saved by the view select via Api.Api.setSubmenuState).
$myCalendarView = app(\Leantime\Core\UI\UiState::class)->get('myCalendarView', 'dayGridMonth');
@endphp

<x-global::layout.page-header icon="fa {{ $tpl->getModulePicture() }}" :eyebrow="__('headline.calendar')">
Expand Down Expand Up @@ -71,10 +70,10 @@


<x-global::forms.select id="my-select" style="margin-right:5px;" class="right">
<option class="fc-timeGridDay-button fc-button fc-state-default fc-corner-right" value="timeGridDay" {{ session('usersettings.submenuToggle.myCalendarView') == 'timeGridDay' ? 'selected' : '' }}>{{ __('buttons.day') }}</option>
<option class="fc-timeGridWeek-button fc-button fc-state-default fc-corner-right" value="timeGridWeek" {{ session('usersettings.submenuToggle.myCalendarView') == 'timeGridWeek' ? 'selected' : '' }}>{{ __('buttons.week') }}</option>
<option class="fc-dayGridMonth-button fc-button fc-state-default fc-corner-right" value="dayGridMonth" {{ session('usersettings.submenuToggle.myCalendarView') == 'dayGridMonth' ? 'selected' : '' }}>{{ __('buttons.month') }}</option>
<option class="fc-multiMonthYear-button fc-button fc-state-default fc-corner-right" value="multiMonthYear" {{ session('usersettings.submenuToggle.myCalendarView') == 'multiMonthYear' ? 'selected' : '' }}>{{ __('label.year') }}</option>
<option class="fc-timeGridDay-button fc-button fc-state-default fc-corner-right" value="timeGridDay" {{ $myCalendarView == 'timeGridDay' ? 'selected' : '' }}>{{ __('buttons.day') }}</option>
<option class="fc-timeGridWeek-button fc-button fc-state-default fc-corner-right" value="timeGridWeek" {{ $myCalendarView == 'timeGridWeek' ? 'selected' : '' }}>{{ __('buttons.week') }}</option>
<option class="fc-dayGridMonth-button fc-button fc-state-default fc-corner-right" value="dayGridMonth" {{ $myCalendarView == 'dayGridMonth' ? 'selected' : '' }}>{{ __('buttons.month') }}</option>
<option class="fc-multiMonthYear-button fc-button fc-state-default fc-corner-right" value="multiMonthYear" {{ $myCalendarView == 'multiMonthYear' ? 'selected' : '' }}>{{ __('label.year') }}</option>
</x-global::forms.select>
</div>
</div>
Expand Down Expand Up @@ -158,7 +157,7 @@
timeZone: leantime.i18n.__("usersettings.timezone"),
height: 'calc(100% - 40px)',
stickyHeaderDates: true,
initialView: '{{ session('usersettings.submenuToggle.myCalendarView') }}',
initialView: '{{ $myCalendarView }}',
eventSources:eventSources,
editable: true,
headerToolbar: false,
Expand Down
50 changes: 15 additions & 35 deletions app/Domain/Menu/Repositories/Menu.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Leantime\Core\Events\DispatchesEvents;
use Leantime\Core\Language as LanguageCore;
use Leantime\Core\Modules\ProjectModules;
use Leantime\Core\UI\UiState;
use Leantime\Domain\Auth\Services\Auth as AuthService;
use Leantime\Domain\Setting\Repositories\Setting as SettingRepository;
use Leantime\Domain\Tickets\Services\Tickets as TicketService;
Expand Down Expand Up @@ -94,47 +95,31 @@ public function __construct(
/** @var TicketService */
private TicketService $ticketsService,
private ProjectModules $projectModules,
) {
if (session()->exists('usersettings.submenuToggle') === false && session()->exists('userdata') === true) {
$setting = $this->settingsRepo;
session([
'usersettings.submenuToggle' => safe_unserialize(
$setting->getSetting('usersetting.'.session('userdata.id').'.submenuToggle'), []
),
]);
}
}
) {}

/**
* setSubmenuState - Set the state of the submenu (open or closed)
* setSubmenuState - Save a UI state of the current user (submenu / accordion open or closed,
* a sidebar width, a calendar view …).
*
* Stored per state in the database (UiState), not in the session: parallel requests can't
* overwrite each other, and the next page load shows it.
*
* @param string $submenu Submenu identifier
* @param string $state New state (open / closed)
* @param string $submenu State name, e.g. "accordion_content-tickets-organization"
* @param string $state New value ("open", "closed", "320" …)
*/
public function setSubmenuState(string $submenu, string $state): void
{

if (session()->exists('usersettings.submenuToggle') && is_array(session('usersettings.submenuToggle'))) {
session(['usersettings.submenuToggle.'.$submenu => $state]);
}

$setting = $this->settingsRepo;
$setting->saveSetting('usersetting.'.session('userdata.id').'.submenuToggle', serialize(session('usersettings.submenuToggle')));
app(UiState::class)->set($submenu, $state);
}

/**
* getSubmenuState - Gets the state of the submenu (open or closed)
* getSubmenuState - A saved UI state of the current user, or false when never saved.
*
* @param string $submenu Submenu identifier
* @param string $submenu State name
*/
public function getSubmenuState(string $submenu)
public function getSubmenuState(string $submenu): string|false
{
$setting = $this->settingsRepo;
$subStructure = $setting->getSetting('usersetting.'.session('userdata.id').'.submenuToggle');

session(['usersettings.submenuToggle' => safe_unserialize($subStructure, [])]);

return session('usersettings.submenuToggle.'.$submenu) ?? false;
return app(UiState::class)->get($submenu) ?? false;
}

/**
Expand Down Expand Up @@ -203,10 +188,6 @@ public function getMenuStructure(string $menuType = ''): array

$menuStructure = $this->menuStructures[$menuType];

if (session()->exists('usersettings.submenuToggle') === false || is_array(session('usersettings.submenuToggle')) === false) {
session(['usersettings.submenuToggle' => []]);
}

ksort($menuStructure);

foreach ($menuStructure as $key => $element) {
Expand Down Expand Up @@ -239,8 +220,7 @@ public function getMenuStructure(string $menuType = ''): array
if ($element['visual'] == 'always') {
$submenuState = 'open';
} else {
$submenuState = session('usersettings.submenuToggle.'.$element['id']) ?? $element['visual'];
session(['usersettings.submenuToggle.'.$element['id'] => $submenuState]);
$submenuState = app(UiState::class)->get($element['id']) ?? $element['visual'];
}
$menuStructure[$key]['visual'] = $submenuState;

Expand Down
2 changes: 1 addition & 1 deletion app/Domain/Menu/Templates/partials/clientGroup.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
@foreach($projects as $project)

@php
$parentState = session("usersettings.submenuToggle.".$prefix.'-projectSelectorlist-group-'.$project['clientId'], 'closed');
$parentState = app(\Leantime\Core\UI\UiState::class)->get($prefix.'-projectSelectorlist-group-'.$project['clientId'], 'closed');
@endphp

@if(
Expand Down
4 changes: 2 additions & 2 deletions app/Domain/Menu/Templates/partials/projectGroup.blade.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
@php
$groupState = session("usersettings.submenuToggle.".$prefix.'-projectSelectorlist-group-'.$parent, 'closed');
$groupState = app(\Leantime\Core\UI\UiState::class)->get($prefix.'-projectSelectorlist-group-'.$parent, 'closed');
@endphp
<ul id="{{ $prefix }}-projectSelectorlist-group-{{ $parent }}" class="level-{{ $level }} projectGroup {{ $groupState }}">
@foreach($projects as $project)
Expand All @@ -14,7 +14,7 @@

<li class="projectLineItem hasSubtitle {{ session("currentProject") == $project['id'] ? "active" : '' }}" >
@php
$parentState = session("usersettings.submenuToggle.".$prefix.'-projectSelectorlist-group-'.$project['id'], 'closed');
$parentState = app(\Leantime\Core\UI\UiState::class)->get($prefix.'-projectSelectorlist-group-'.$project['id'], 'closed');
@endphp

@if((empty($project['children']) || count($project['children']) ==0))
Expand Down
Loading
Loading