From d0fedad1bf498d7613fe7e776dc71effab5906be Mon Sep 17 00:00:00 2001 From: Rias Date: Wed, 1 Jul 2026 11:36:49 +0200 Subject: [PATCH 1/6] Inertia user preferences --- resources/js/pages/users/Preferences.vue | 328 ++++++++++++++++++ resources/templates/users/_preferences.twig | 211 ----------- routes/actions.php | 2 - routes/cp.php | 1 + .../Users/PreferencesController.php | 90 +---- src/Http/Requests/UserPreferencesRequest.php | 93 +++++ .../ViewModels/UserPreferencesViewModel.php | 144 ++++++++ .../User/PreferencesControllerTest.php | 149 ++++---- .../TypeScriptTransformerServiceProvider.php | 2 + 9 files changed, 666 insertions(+), 354 deletions(-) create mode 100644 resources/js/pages/users/Preferences.vue delete mode 100644 resources/templates/users/_preferences.twig create mode 100644 src/Http/Requests/UserPreferencesRequest.php create mode 100644 src/Http/ViewModels/UserPreferencesViewModel.php diff --git a/resources/js/pages/users/Preferences.vue b/resources/js/pages/users/Preferences.vue new file mode 100644 index 00000000000..b728a32a520 --- /dev/null +++ b/resources/js/pages/users/Preferences.vue @@ -0,0 +1,328 @@ + + + diff --git a/resources/templates/users/_preferences.twig b/resources/templates/users/_preferences.twig deleted file mode 100644 index f88bb3eb9b6..00000000000 --- a/resources/templates/users/_preferences.twig +++ /dev/null @@ -1,211 +0,0 @@ -{% import '_includes/forms.twig' as forms %} -{% set orientation = I18N.getLocale().getOrientation() -%} - -
-

{{ 'General'|t('app') }}

- - {{ forms.languageMenuField({ - id: 'preferredLanguage', - name: 'preferredLanguage', - label: 'Language'|t('app'), - instructions: 'The language that the control panel should use.'|t('app'), - options: craft.cp.getLanguageOptions(false, true, true), - value: userLanguage, - appOnly: true, - }) }} - - {{ forms.languageMenuField({ - id: 'preferredLocale', - name: 'preferredLocale', - label: 'Formatting Locale'|t('app'), - instructions: 'The locale that should be used for date and number formatting.'|t('app'), - options: [ - {label: 'Same as language'|t('app'), value: '__blank__'}, - ...craft.cp.getLanguageOptions(false, true), - ], - value: userLocale, - }) }} - - {{ forms.selectField({ - label: 'Week Start Day'|t('app'), - id: 'weekStartDay', - name: 'weekStartDay', - options: I18N.getLocale().getWeekDayNames(), - value: currentUser.getPreference('weekStartDay', config('craft.general.defaultWeekStartDay')) - }) }} - - {{ forms.timeZoneField({ - label: 'Time Zone'|t('app'), - id: 'time-zone', - name: 'timeZone', - options: [ - {label: 'System time zone ({abbr})'|t('app', {abbr: timeZoneAbbr}), value: '__blank__'}, - ...craft.cp.getTimeZoneOptions(offsetDate ?? null), - ], - value: currentUser.getPreference('timeZone'), - }) }} -
- -
- -
-

{{ 'Accessibility'|t('app') }}

- - {% set a11yDefaults = config('craft.general.accessibilityDefaults') %} - {{ forms.checkboxGroupField({ - label: 'Display Settings'|t('app'), - options: [ - { - label: 'Use shapes to represent statuses'|t('app'), - name: 'useShapes', - id: 'useShapes', - checked: currentUser.getPreference('useShapes') ?? a11yDefaults['useShapes'] ?? false, - }, - { - label: 'Underline links'|t('app'), - name: 'underlineLinks', - id: 'underlineLinks', - checked: currentUser.getPreference('underlineLinks') ?? a11yDefaults['underlineLinks'] ?? false, - }, - ], - }) }} - - {{ forms.checkboxGroupField({ - label: 'General Settings'|t('app'), - options: [ - { - label: 'Disable autofocus'|t('app'), - name: 'disableAutofocus', - id: 'disableAutofocus', - checked: currentUser.getPreference('disableAutofocus') ?? a11yDefaults['disableAutofocus'] ?? false, - }, - ], - }) }} - - {{ forms.selectField({ - label: 'Notification Duration'|t('app'), - instructions: 'How long notifications should be shown before they disappear automatically.'|t('app'), - name: 'notificationDuration', - id: 'notificationDuration', - options: [ - {value: 2000, label: '{num, number} {num, plural, =1{second} other{seconds}}'|t('app', {num: 2})}, - {value: 5000, label: '{num, number} {num, plural, =1{second} other{seconds}}'|t('app', {num: 5})}, - {value: 10000, label: '{num, number} {num, plural, =1{second} other{seconds}}'|t('app', {num: 10})}, - {value: 0, label: 'Show them indefinitely'|t('app')}, - ], - value: currentUser.getPreference('notificationDuration') ?? a11yDefaults['notificationDuration'] ?? 5000, - }) }} - - {{ forms.buttonGroupField({ - id: 'notification-position', - name: 'notificationPosition', - label: 'Notification Position'|t('app'), - options: [ - { - icon: orientation == 'ltr' ? 'notification-top-left' : 'notification-top-right', - value: 'start-start', - attributes: { - title: orientation == 'ltr' ? 'Top-Left'|t('app') : 'Top-Right'|t('app'), - aria: { - label: orientation == 'ltr' ? 'Top-Left'|t('app') : 'Top-Right'|t('app'), - }, - }, - }, - { - icon: orientation == 'ltr' ? 'notification-top-right' : 'notification-top-left', - value: 'start-end', - attributes: { - title: orientation == 'ltr' ? 'Top-Right'|t('app') : 'Top-Left'|t('app'), - aria: { - label: orientation == 'ltr' ? 'Top-Right'|t('app') : 'Top-Left'|t('app'), - }, - }, - }, - { - icon: orientation == 'ltr' ? 'notification-bottom-left' : 'notification-bottom-right', - value: 'end-start', - attributes: { - title: orientation == 'ltr' ? 'Bottom-Left'|t('app') : 'Bottom-Right'|t('app'), - aria: { - label: orientation == 'ltr' ? 'Bottom-Left'|t('app') : 'Bottom-Right'|t('app'), - }, - }, - }, - { - icon: orientation == 'ltr' ? 'notification-bottom-right' : 'notification-bottom-left', - value: 'end-end', - attributes: { - title: orientation == 'ltr' ? 'Bottom-Right'|t('app') : 'Bottom-Left'|t('app'), - aria: { - label: orientation == 'ltr' ? 'Bottom-Right'|t('app') : 'Bottom-Left'|t('app'), - }, - }, - }, - ], - value: currentUser.getPreference('notificationPosition') ?? a11yDefaults['notificationPosition'] ?? 'end-start', - }) }} - - {{ forms.buttonGroupField({ - id: 'slideout-position', - name: 'slideoutPosition', - label: 'Slideout Position'|t('app'), - options: [ - { - icon: orientation == 'ltr' ? 'slideout-left' : 'slideout-right', - value: 'start', - attributes: { - title: orientation == 'ltr' ? 'Left'|t('app') : 'Right'|t('app'), - aria: { - label: orientation == 'ltr' ? 'Left'|t('app') : 'Right'|t('app'), - }, - }, - }, - { - icon: orientation == 'ltr' ? 'slideout-right' : 'slideout-left', - value: 'end', - attributes: { - title: orientation == 'ltr' ? 'Right'|t('app') : 'Left'|t('app'), - aria: { - label: orientation == 'ltr' ? 'Right'|t('app') : 'Left'|t('app'), - }, - }, - }, - ], - value: currentUser.getPreference('slideoutPosition') ?? a11yDefaults['slideoutPosition'] ?? 'end', - }) }} - -
- -{% if currentUser.admin %} -
- -
-

{{ 'Development'|t('app') }}

- - {{ forms.checkboxGroupField({ - label: 'Development Settings'|t('app'), - options: [ - { - label: 'Show field handles in edit forms'|t('app'), - name: 'showFieldHandles', - id: 'showFieldHandles', - checked: currentUser.getPreference('showFieldHandles') - }, - { - label: 'Profile Twig templates when Dev Mode is disabled'|t('app'), - name: 'profileTemplates', - id: 'profileTemplates', - checked: currentUser.getPreference('profileTemplates') - }, - { - label: 'Show full exception views when Dev Mode is disabled'|t('app'), - name: 'showExceptionView', - id: 'showExceptionView', - checked: currentUser.getPreference('showExceptionView') - }, - ], - }) }} -
-{% endif %} - -{% hook 'cp.users.edit.prefs' %} diff --git a/routes/actions.php b/routes/actions.php index f2795ae9497..f27be4a852f 100644 --- a/routes/actions.php +++ b/routes/actions.php @@ -81,7 +81,6 @@ use CraftCms\Cms\Http\Controllers\Users\PasskeysController as UserPasskeysController; use CraftCms\Cms\Http\Controllers\Users\PasswordController; use CraftCms\Cms\Http\Controllers\Users\PhotoController; -use CraftCms\Cms\Http\Controllers\Users\PreferencesController; use CraftCms\Cms\Http\Controllers\Users\RecoveryCodesController; use CraftCms\Cms\Http\Controllers\Users\SaveUserController; use CraftCms\Cms\Http\Controllers\Users\SaveUsersFieldLayoutController; @@ -483,7 +482,6 @@ Route::post('users/unsuspend-user', [SuspendController::class, 'unsuspend']); }); - Route::post('users/save-preferences', [PreferencesController::class, 'store']); Route::post('users/render-photo-input', [PhotoController::class, 'renderInput']); Route::post('users/upload-user-photo', [PhotoController::class, 'upload']); Route::post('users/delete-user-photo', [PhotoController::class, 'destroy']); diff --git a/routes/cp.php b/routes/cp.php index ff19345e068..ae625e838ff 100644 --- a/routes/cp.php +++ b/routes/cp.php @@ -146,6 +146,7 @@ Route::get('myaccount/passkeys', [PasskeysController::class, 'index']); Route::get('myaccount/password', [PasswordController::class, 'index']); Route::get('myaccount/preferences', [PreferencesController::class, 'index']); + Route::patch('myaccount/preferences', [PreferencesController::class, 'update']); Route::middleware([ RequireEdition::class.':'.Edition::Team->value, diff --git a/src/Http/Controllers/Users/PreferencesController.php b/src/Http/Controllers/Users/PreferencesController.php index 4c6ff978223..65c89062e12 100644 --- a/src/Http/Controllers/Users/PreferencesController.php +++ b/src/Http/Controllers/Users/PreferencesController.php @@ -4,14 +4,10 @@ namespace CraftCms\Cms\Http\Controllers\Users; -use CraftCms\Cms\Config\GeneralConfig; +use CraftCms\Cms\Http\Requests\UserPreferencesRequest; use CraftCms\Cms\Http\RespondsWithFlash; use CraftCms\Cms\Http\Responses\CpScreenResponse; -use CraftCms\Cms\ProjectConfig\ProjectConfig; -use CraftCms\Cms\Support\DateTimeHelper; -use CraftCms\Cms\Support\Env; -use CraftCms\Cms\Translation\I18N; -use CraftCms\Cms\Translation\Locale; +use CraftCms\Cms\Http\ViewModels\UserPreferencesViewModel; use CraftCms\Cms\User\Users; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; @@ -23,93 +19,25 @@ use EditUserTrait; use RespondsWithFlash; - public function index(Request $request, I18N $i18N, GeneralConfig $generalConfig, ProjectConfig $projectConfig): CpScreenResponse + public function index(Request $request): CpScreenResponse { - $currentUser = $request->craftUser(); - if (! $currentUser) { + if (! $currentUser = $request->craftUser()) { abort(401); } $user = $currentUser->asElement(); - $response = $this->asEditUserScreen($user, self::SCREEN_PREFERENCES); - - // user language - $userLanguage = $user->getPreferredLanguage(); - - if ( - ! $userLanguage || - $i18N->getAppLocales()->doesntContain(fn (Locale $locale) => $locale->id === Env::parse($userLanguage)) - ) { - $userLanguage = app()->getLocale(); - } - - // user locale - $userLocale = $user->getPreferredLocale(); - - if ( - ! $userLocale || - $i18N->getAllLocales()->doesntContain(fn (Locale $locale) => $locale->id === Env::parse($userLocale)) - ) { - $userLocale = $generalConfig->defaultCpLocale; - } - - // time zone - // (can't call `Craft::$app->getTimeZone()` here because that could be set to the user preference) - $timeZone = $generalConfig->timezone ?? $projectConfig->get('system.timeZone'); - $timeZoneAbbr = $timeZone ? DateTimeHelper::timeZoneAbbreviation(Env::parse($timeZone)) : 'UTC'; - - $response->action('users/save-preferences'); - $response->contentTemplate('users/_preferences', compact( - 'userLanguage', - 'userLocale', - 'timeZoneAbbr', - )); - - return $response; + return $this->asEditUserScreen($user, self::SCREEN_PREFERENCES) + ->inertiaPage('users/Preferences', new UserPreferencesViewModel($user)); } - public function store(Request $request, Users $users): Response + public function update(UserPreferencesRequest $request, Users $users): Response { - $user = $request->craftUser(); - if (! $user) { + if (! $user = $request->craftUser()) { abort(401); } - $preferredLocale = $request->input('preferredLocale', $user->getPreference('locale')) ?: null; - - if ($preferredLocale === '__blank__') { - $preferredLocale = null; - } - - $timeZone = $request->input('timeZone', $user->getPreference('timezone')) ?: null; - - if ($timeZone === '__blank__') { - $timeZone = null; - } - - $preferences = [ - 'language' => $request->input('preferredLanguage', $user->getPreference('language')), - 'locale' => $preferredLocale, - 'weekStartDay' => $request->input('weekStartDay', $user->getPreference('weekStartDay')), - 'timeZone' => $timeZone, - 'useShapes' => (bool) $request->input('useShapes', $user->getPreference('useShapes')), - 'underlineLinks' => (bool) $request->input('underlineLinks', $user->getPreference('underlineLinks')), - 'disableAutofocus' => $request->input('disableAutofocus', $user->getPreference('disableAutofocus')), - 'notificationDuration' => $request->input('notificationDuration', $user->getPreference('notificationDuration')), - 'notificationPosition' => $request->input('notificationPosition', $user->getPreference('notificationPosition')), - 'slideoutPosition' => $request->input('slideoutPosition', $user->getPreference('slideoutPosition')), - ]; - - if ($user->isAdmin()) { - $preferences = array_merge($preferences, [ - 'showFieldHandles' => (bool) $request->input('showFieldHandles', $user->getPreference('showFieldHandles')), - 'showExceptionView' => (bool) $request->input('showExceptionView', $user->getPreference('showExceptionView')), - 'profileTemplates' => (bool) $request->input('profileTemplates', $user->getPreference('profileTemplates')), - ]); - } - - $users->saveUserPreferences($user, $preferences); + $users->saveUserPreferences($user, $request->preferences()); return $this->asSuccess(t('Preferences saved.')); } diff --git a/src/Http/Requests/UserPreferencesRequest.php b/src/Http/Requests/UserPreferencesRequest.php new file mode 100644 index 00000000000..e0840f12a2e --- /dev/null +++ b/src/Http/Requests/UserPreferencesRequest.php @@ -0,0 +1,93 @@ +craftUser(); + + if (! $user) { + return false; + } + if ($user->isAdmin()) { + return true; + } + + return ! $this->hasAny(self::adminOnlyPreferences()); + } + + public function rules(): array + { + return [ + 'preferredLanguage' => ['sometimes', 'string', Rule::in(I18N::getAppLocaleIds()->all())], + 'preferredLocale' => ['sometimes', 'nullable', 'string', Rule::in(['__blank__', ...I18N::getAllLocaleIds()->all()])], + 'weekStartDay' => ['sometimes', 'integer', 'between:0,6'], + 'timeZone' => ['sometimes', 'nullable', 'string', Rule::when($this->input('timeZone') !== '__blank__', [new TimezoneRule])], + 'useShapes' => ['sometimes', 'boolean'], + 'underlineLinks' => ['sometimes', 'boolean'], + 'disableAutofocus' => ['sometimes', 'boolean'], + 'notificationDuration' => ['sometimes', 'integer', Rule::in([0, 2000, 5000, 10000])], + 'notificationPosition' => ['sometimes', 'string', Rule::in(['start-start', 'start-end', 'end-start', 'end-end'])], + 'slideoutPosition' => ['sometimes', 'string', Rule::in(['start', 'end'])], + 'showFieldHandles' => ['sometimes', 'boolean'], + 'showExceptionView' => ['sometimes', 'boolean'], + 'profileTemplates' => ['sometimes', 'boolean'], + ]; + } + + public function preferences(): array + { + $validated = $this->safe(); + $preferences = []; + + foreach ($this->preferenceMap() as $inputKey => $preferenceKey) { + if (! $validated->has($inputKey)) { + continue; + } + + $value = $validated->input($inputKey); + $preferences[$preferenceKey] = $value === '__blank__' ? null : $value; + } + + return $preferences; + } + + /** @return string[] */ + public static function adminOnlyPreferences(): array + { + return [ + 'showFieldHandles', + 'showExceptionView', + 'profileTemplates', + ]; + } + + /** @return array */ + private function preferenceMap(): array + { + return [ + 'preferredLanguage' => 'language', + 'preferredLocale' => 'locale', + 'weekStartDay' => 'weekStartDay', + 'timeZone' => 'timeZone', + 'useShapes' => 'useShapes', + 'underlineLinks' => 'underlineLinks', + 'disableAutofocus' => 'disableAutofocus', + 'notificationDuration' => 'notificationDuration', + 'notificationPosition' => 'notificationPosition', + 'slideoutPosition' => 'slideoutPosition', + 'showFieldHandles' => 'showFieldHandles', + 'showExceptionView' => 'showExceptionView', + 'profileTemplates' => 'profileTemplates', + ]; + } +} diff --git a/src/Http/ViewModels/UserPreferencesViewModel.php b/src/Http/ViewModels/UserPreferencesViewModel.php new file mode 100644 index 00000000000..8a7ac999fd6 --- /dev/null +++ b/src/Http/ViewModels/UserPreferencesViewModel.php @@ -0,0 +1,144 @@ + */ + public array $preferences; + + /** @var array}> */ + public array $languageOptions; + + /** @var array}> */ + public array $localeOptions; + + /** @var array */ + public array $weekStartDayOptions; + + /** @var array|null}> */ + public array $timeZoneOptions; + + public string $orientation; + + public bool $isAdmin; + + public function __construct( + private readonly User $user, + ) { + $a11yDefaults = Cms::config()->accessibilityDefaults; + + $this->preferences = [ + 'preferredLanguage' => $this->userLanguage(), + 'preferredLocale' => $this->userLocale(), + 'weekStartDay' => $user->getPreference('weekStartDay', Cms::config()->defaultWeekStartDay), + 'timeZone' => $user->getPreference('timeZone'), + 'useShapes' => $user->getPreference('useShapes') ?? $a11yDefaults['useShapes'] ?? false, + 'underlineLinks' => $user->getPreference('underlineLinks') ?? $a11yDefaults['underlineLinks'] ?? false, + 'disableAutofocus' => $user->getPreference('disableAutofocus') ?? $a11yDefaults['disableAutofocus'] ?? false, + 'notificationDuration' => $user->getPreference('notificationDuration') ?? $a11yDefaults['notificationDuration'] ?? 5000, + 'notificationPosition' => $user->getPreference('notificationPosition') ?? $a11yDefaults['notificationPosition'] ?? 'end-start', + 'slideoutPosition' => $user->getPreference('slideoutPosition') ?? $a11yDefaults['slideoutPosition'] ?? 'end', + 'showFieldHandles' => $user->getPreference('showFieldHandles') ?? false, + 'showExceptionView' => $user->getPreference('showExceptionView') ?? false, + 'profileTemplates' => $user->getPreference('profileTemplates') ?? false, + ]; + + $this->languageOptions = SelectOptions::getLanguageOptions(showLocalizedNames: true, appLocales: true); + $this->localeOptions = [ + ['label' => t('Same as language'), 'value' => '__blank__'], + ...SelectOptions::getLanguageOptions(showLocalizedNames: true), + ]; + $this->weekStartDayOptions = $this->weekStartDayOptions(); + $this->timeZoneOptions = $this->timeZoneOptions(); + $this->orientation = I18N::getLocale()->getOrientation(); + $this->isAdmin = $user->isAdmin(); + } + + public function toArray(): array + { + return [ + 'readOnly' => $this->readOnly, + 'preferences' => $this->preferences, + 'languageOptions' => $this->languageOptions, + 'localeOptions' => $this->localeOptions, + 'weekStartDayOptions' => $this->weekStartDayOptions, + 'timeZoneOptions' => $this->timeZoneOptions, + 'orientation' => $this->orientation, + 'isAdmin' => $this->isAdmin, + ]; + } + + private function userLanguage(): string + { + $userLanguage = $this->user->getPreferredLanguage(); + + if ( + ! $userLanguage || + I18N::getAppLocales()->doesntContain(fn (Locale $locale) => $locale->id === Env::parse($userLanguage)) + ) { + return app()->getLocale(); + } + + return $userLanguage; + } + + private function userLocale(): string + { + $userLocale = $this->user->getPreferredLocale(); + + if ( + ! $userLocale || + I18N::getAllLocales()->doesntContain(fn (Locale $locale) => $locale->id === Env::parse($userLocale)) + ) { + return Cms::config()->defaultCpLocale ?? app()->getLocale(); + } + + return $userLocale; + } + + private function systemTimeZoneAbbr(): string + { + $timeZone = Cms::config()->timezone ?? ProjectConfig::get('system.timeZone'); + + return $timeZone ? DateTimeHelper::timeZoneAbbreviation(Env::parse($timeZone)) : 'UTC'; + } + + private function weekStartDayOptions(): array + { + return collect(I18N::getLocale()->getWeekDayNames()) + ->map(fn (string $label, int $value): array => [ + 'label' => $label, + 'value' => (string) $value, + ]) + ->values() + ->all(); + } + + private function timeZoneOptions(): array + { + return [ + [ + 'label' => t('System time zone ({abbr})', ['abbr' => $this->systemTimeZoneAbbr()]), + 'value' => '__blank__', + ], + ...SelectOptions::getTimeZoneOptions(), + ]; + } +} diff --git a/tests/Feature/Http/Controllers/User/PreferencesControllerTest.php b/tests/Feature/Http/Controllers/User/PreferencesControllerTest.php index 2690f55f043..66c642f0f7e 100644 --- a/tests/Feature/Http/Controllers/User/PreferencesControllerTest.php +++ b/tests/Feature/Http/Controllers/User/PreferencesControllerTest.php @@ -1,14 +1,15 @@ logout(); - get(action([PreferencesController::class, 'index']))->assertRedirect(Cms::config()->cpTrigger.'/login'); - postJson(action([PreferencesController::class, 'store']))->assertUnauthorized(); + get(cp_url('myaccount/preferences'))->assertRedirect(); + patchJson(cp_url('myaccount/preferences'))->assertUnauthorized(); }); -test('index', function () { - get(action([PreferencesController::class, 'index'])) +test('index shows preferences page', function () { + $response = get(cp_url('myaccount/preferences')); + + $response ->assertOk() - ->assertSee(t('Preferences')); + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('users/Preferences') + ->where('preferences.preferredLanguage', 'en-US') + ->where('readOnly', false) + ->has('languageOptions') + ->has('localeOptions') + ->has('weekStartDayOptions', 7) + ->has('timeZoneOptions') + ->has('subnav') + ->has('details')); }); -test('store', function () { +test('update saves language', function () { /** @var User $user */ $user = currentUser(); expect($user->getPreference('language'))->toBe('en-US'); - postJson(action([PreferencesController::class, 'store'], [ - 'preferredLanguage' => 'nl-BE', - ]))->assertOk(); + patchJson(cp_url('myaccount/preferences'), [ + 'preferredLanguage' => 'de', + ])->assertOk(); - expect($user->getPreference('language'))->toBe('nl-BE'); + expect($user->getPreference('language'))->toBe('de'); }); -test('store saves multiple preferences at once', function () { +test('update saves multiple preferences at once', function () { /** @var User $user */ $user = currentUser(); - postJson(action([PreferencesController::class, 'store'], [ + patchJson(cp_url('myaccount/preferences'), [ 'preferredLanguage' => 'fr', 'weekStartDay' => 1, 'useShapes' => true, 'underlineLinks' => true, - ]))->assertOk(); + ])->assertOk(); expect($user->getPreference('language'))->toBe('fr'); - expect($user->getPreference('weekStartDay'))->toBe('1'); + expect($user->getPreference('weekStartDay'))->toBe(1); expect($user->getPreference('useShapes'))->toBeTrue(); expect($user->getPreference('underlineLinks'))->toBeTrue(); }); -test('store handles __blank__ value for locale', function () { +test('update handles blank values', function () { /** @var User $user */ $user = currentUser(); - postJson(action([PreferencesController::class, 'store'], [ + patchJson(cp_url('myaccount/preferences'), [ 'preferredLocale' => '__blank__', - ]))->assertOk(); + 'timeZone' => '__blank__', + ])->assertOk(); expect($user->getPreference('locale'))->toBeNull(); + expect($user->getPreference('timeZone'))->toBeNull(); }); -test('store saves notification preferences', function () { +test('update saves notification preferences', function () { /** @var User $user */ $user = currentUser(); - postJson(action([PreferencesController::class, 'store'], [ + patchJson(cp_url('myaccount/preferences'), [ 'notificationDuration' => 5000, - 'notificationPosition' => 'top-right', - ]))->assertOk(); + 'notificationPosition' => 'start-end', + ])->assertOk(); - expect($user->getPreference('notificationDuration'))->toBe('5000'); - expect($user->getPreference('notificationPosition'))->toBe('top-right'); + expect($user->getPreference('notificationDuration'))->toBe(5000); + expect($user->getPreference('notificationPosition'))->toBe('start-end'); }); -test('store saves slideout position preference', function () { +test('update saves slideout position preference', function () { /** @var User $user */ $user = currentUser(); - postJson(action([PreferencesController::class, 'store'], [ - 'slideoutPosition' => 'left', - ]))->assertOk(); + patchJson(cp_url('myaccount/preferences'), [ + 'slideoutPosition' => 'start', + ])->assertOk(); - expect($user->getPreference('slideoutPosition'))->toBe('left'); + expect($user->getPreference('slideoutPosition'))->toBe('start'); }); -test('store saves admin-only preferences for admin users', function () { +test('update saves admin-only preferences for admin users', function () { /** @var User $user */ $user = currentUser(); - if (! $user->admin) { - $this->markTestSkipped('User must be admin for this test'); - } - - postJson(action([PreferencesController::class, 'store'], [ + patchJson(cp_url('myaccount/preferences'), [ 'showFieldHandles' => true, 'showExceptionView' => true, 'profileTemplates' => true, - ]))->assertOk(); + ])->assertOk(); expect($user->getPreference('showFieldHandles'))->toBeTrue(); expect($user->getPreference('showExceptionView'))->toBeTrue(); expect($user->getPreference('profileTemplates'))->toBeTrue(); }); -test('store preserves existing preferences when not provided', function () { +test('update rejects admin-only preferences for non-admin users', function () { + $user = UserModel::factory() + ->withPermissions(['accessCp']) + ->create(); + + actingAs($user->asElement()); + + patchJson(cp_url('myaccount/preferences'), [ + 'showFieldHandles' => true, + ])->assertForbidden(); +}); + +test('update preserves existing preferences when not provided', function () { /** @var User $user */ $user = currentUser(); - // Set initial preference - postJson(action([PreferencesController::class, 'store'], [ + patchJson(cp_url('myaccount/preferences'), [ 'preferredLanguage' => 'de', 'weekStartDay' => 0, - ]))->assertOk(); + ])->assertOk(); - // Update only language, weekStartDay should persist - postJson(action([PreferencesController::class, 'store'], [ + patchJson(cp_url('myaccount/preferences'), [ 'preferredLanguage' => 'es', - ]))->assertOk(); + ])->assertOk(); expect($user->getPreference('language'))->toBe('es'); - expect($user->getPreference('weekStartDay'))->toBe('0'); + expect($user->getPreference('weekStartDay'))->toBe(0); }); -test('store handles boolean preferences correctly', function () { +test('update handles boolean preferences correctly', function () { /** @var User $user */ $user = currentUser(); - postJson(action([PreferencesController::class, 'store'], [ + patchJson(cp_url('myaccount/preferences'), [ 'useShapes' => false, 'underlineLinks' => false, 'disableAutofocus' => true, - ]))->assertOk(); + ])->assertOk(); expect($user->getPreference('useShapes'))->toBeFalse(); expect($user->getPreference('underlineLinks'))->toBeFalse(); - expect($user->getPreference('disableAutofocus'))->toBe('1'); + expect($user->getPreference('disableAutofocus'))->toBeTrue(); }); -test('index shows preferences page with user language', function () { - $response = get(action([PreferencesController::class, 'index'])); - - $response->assertOk(); - - // Should render preferences template - expect($response->status())->toBe(200); +test('update validates preference values', function () { + patchJson(cp_url('myaccount/preferences'), [ + 'preferredLanguage' => 'not-a-locale', + 'weekStartDay' => 8, + 'timeZone' => 'not-a-timezone', + 'notificationDuration' => 1234, + 'notificationPosition' => 'top-right', + 'slideoutPosition' => 'left', + ])->assertJsonValidationErrors([ + 'preferredLanguage', + 'weekStartDay', + 'timeZone', + 'notificationDuration', + 'notificationPosition', + 'slideoutPosition', + ]); }); -test('store returns success message', function () { - postJson(action([PreferencesController::class, 'store'], [ +test('update returns success message', function () { + patchJson(cp_url('myaccount/preferences'), [ 'preferredLanguage' => 'en-US', - ])) + ]) ->assertOk() ->assertJson([ 'message' => t('Preferences saved.'), diff --git a/workbench/app/Providers/TypeScriptTransformerServiceProvider.php b/workbench/app/Providers/TypeScriptTransformerServiceProvider.php index d03fef8f1f5..bf9d85f59b8 100644 --- a/workbench/app/Providers/TypeScriptTransformerServiceProvider.php +++ b/workbench/app/Providers/TypeScriptTransformerServiceProvider.php @@ -10,6 +10,7 @@ use CraftCms\Cms\Gql\Data\GqlSchema; use CraftCms\Cms\Gql\Data\GqlToken; use CraftCms\Cms\Http\ViewModels\UserPermissionsViewModel; +use CraftCms\Cms\Http\ViewModels\UserPreferencesViewModel; use CraftCms\Cms\Image\Data\ImageTransform; use CraftCms\Cms\Route\Data\Route; use CraftCms\Cms\Update\Data\Updates; @@ -45,6 +46,7 @@ protected function configure(TypeScriptTransformerConfigFactory $config): void Route::class, Updates::class, UserPermissionsViewModel::class, + UserPreferencesViewModel::class, UserSettings::class, ], [ From 910ddb0dd16a90852e19a0bf4503cab54ef2039a Mon Sep 17 00:00:00 2001 From: Rias Date: Thu, 2 Jul 2026 14:52:13 +0200 Subject: [PATCH 2/6] Add hook support & HtmlFragment + HtmlFragmentRenderer --- .../craftcms-cp/src/utilities/dom.test.ts | 54 +++++++- packages/craftcms-cp/src/utilities/dom.ts | 21 ++- .../components/HtmlFragmentRenderer.vue | 101 ++++++++++++++ resources/js/pages/users/Preferences.vue | 4 +- .../ViewModels/UserPreferencesViewModel.php | 19 +++ src/Support/Facades/HtmlStack.php | 1 + src/View/HtmlFragment.php | 38 +++++ src/View/HtmlStack.php | 52 ++++++- .../User/PreferencesControllerTest.php | 25 +++- tests/Unit/View/HtmlStackCaptureTest.php | 131 ++++++++++++++++++ .../TypeScriptTransformerServiceProvider.php | 2 + 11 files changed, 436 insertions(+), 12 deletions(-) create mode 100644 resources/js/common/components/HtmlFragmentRenderer.vue create mode 100644 src/View/HtmlFragment.php create mode 100644 tests/Unit/View/HtmlStackCaptureTest.php diff --git a/packages/craftcms-cp/src/utilities/dom.test.ts b/packages/craftcms-cp/src/utilities/dom.test.ts index 081217883fe..3bfdef3d172 100644 --- a/packages/craftcms-cp/src/utilities/dom.test.ts +++ b/packages/craftcms-cp/src/utilities/dom.test.ts @@ -40,9 +40,12 @@ describe('appendHeadHtml', () => { test('appends a script element to head', async () => { const {appendHeadHtml} = await freshImport(); - await appendHeadHtml( + const append = appendHeadHtml( '' ); + document.head.querySelector('script')!.dispatchEvent(new Event('load')); + await append; + const scripts = document.head.querySelectorAll('script'); expect(scripts.length).toBe(1); expect(scripts[0]!.getAttribute('src')).toBe( @@ -80,9 +83,12 @@ describe('appendHeadHtml', () => { test('preserves script attributes when appending', async () => { const {appendHeadHtml} = await freshImport(); - await appendHeadHtml( + const append = appendHeadHtml( '' ); + document.head.querySelector('script')!.dispatchEvent(new Event('load')); + await append; + const script = document.head.querySelector('script')!; expect(script.getAttribute('src')).toBe('https://example.com/b.js'); expect(script.getAttribute('type')).toBe('module'); @@ -106,11 +112,29 @@ describe('appendBodyHtml', () => { test('appends script with src to body', async () => { const {appendBodyHtml} = await freshImport(); - await appendBodyHtml(''); + const append = appendBodyHtml( + '' + ); + document.body.querySelector('script')!.dispatchEvent(new Event('load')); + await append; + const scripts = document.body.querySelectorAll('script'); expect(scripts.length).toBe(1); expect(scripts[0]!.getAttribute('src')).toBe('https://example.com/body.js'); }); + + test('appends elements to a provided parent', async () => { + const {appendElementHtml} = await freshImport(); + const parent = document.createElement('div'); + + const dispose = await appendElementHtml('

Hello

', parent); + + expect(parent.querySelector('#child')!.textContent).toBe('Hello'); + + dispose(); + + expect(parent.querySelector('#child')).toBeNull(); + }); }); describe('CSS deduplication', () => { @@ -150,8 +174,12 @@ describe('JS deduplication', () => { test('does not add duplicate script src', async () => { const {appendBodyHtml} = await freshImport(); const js = ''; + const firstAppend = appendBodyHtml(js); + document.body.querySelector('script')!.dispatchEvent(new Event('load')); + await firstAppend; + await appendBodyHtml(js); - await appendBodyHtml(js); + const scripts = document.body.querySelectorAll('script'); expect(scripts.length).toBe(1); }); @@ -164,4 +192,22 @@ describe('JS deduplication', () => { const scripts = document.body.querySelectorAll('script'); expect(scripts.length).toBe(2); }); + + test('waits for external scripts before appending subsequent nodes', async () => { + const {appendElementHtml} = await freshImport(); + const parent = document.createElement('div'); + const append = appendElementHtml( + '', + parent + ); + + await Promise.resolve(); + + expect(parent.querySelector('#after-script')).toBeNull(); + + parent.querySelector('script')!.dispatchEvent(new Event('load')); + await append; + + expect(parent.querySelector('#after-script')).not.toBeNull(); + }); }); diff --git a/packages/craftcms-cp/src/utilities/dom.ts b/packages/craftcms-cp/src/utilities/dom.ts index 0f133bf3263..bff002a26a4 100644 --- a/packages/craftcms-cp/src/utilities/dom.ts +++ b/packages/craftcms-cp/src/utilities/dom.ts @@ -8,7 +8,14 @@ let existingJs: string[] | null = null; */ export type AppendHtmlDisposer = () => void; -async function appendHtml( +function waitForScript(script: HTMLScriptElement): Promise { + return new Promise((resolve) => { + script.addEventListener('load', () => resolve(), {once: true}); + script.addEventListener('error', () => resolve(), {once: true}); + }); +} + +export async function appendElementHtml( html: string, parent: HTMLElement ): Promise { @@ -72,6 +79,8 @@ async function appendHtml( if (node instanceof HTMLScriptElement) { const script = document.createElement('script'); + let scriptLoaded: Promise | null = null; + Array.from(node.attributes).forEach((attr) => { script.setAttribute(attr.name, attr.value); }); @@ -91,12 +100,18 @@ async function appendHtml( existingJs.push(src); jsAdded.push(src); script.async = false; + scriptLoaded = waitForScript(script); } else { script.textContent = node.textContent; } parent.appendChild(script); appended.push(script); + + if (scriptLoaded) { + await scriptLoaded; + } + continue; } @@ -116,7 +131,7 @@ async function appendHtml( export async function appendHeadHtml( html: string ): Promise { - return appendHtml(html, document.head); + return appendElementHtml(html, document.head); } /** @@ -127,5 +142,5 @@ export async function appendHeadHtml( export async function appendBodyHtml( html: string ): Promise { - return appendHtml(html, document.body); + return appendElementHtml(html, document.body); } diff --git a/resources/js/common/components/HtmlFragmentRenderer.vue b/resources/js/common/components/HtmlFragmentRenderer.vue new file mode 100644 index 00000000000..3f193ce8e78 --- /dev/null +++ b/resources/js/common/components/HtmlFragmentRenderer.vue @@ -0,0 +1,101 @@ + + + diff --git a/resources/js/pages/users/Preferences.vue b/resources/js/pages/users/Preferences.vue index b728a32a520..3c0903cd1a4 100644 --- a/resources/js/pages/users/Preferences.vue +++ b/resources/js/pages/users/Preferences.vue @@ -6,6 +6,7 @@ import CraftCombobox from '@/common/form/CraftCombobox.vue'; import Select from '@/common/form/Select.vue'; import CheckboxGroup from '@/common/form/CheckboxGroup.vue'; + import HtmlFragmentRenderer from '@/common/components/HtmlFragmentRenderer.vue'; import {useSettingsSave} from '@/modules/settings/composables/useSettingsSave'; import {update} from '@actions/Users/PreferencesController'; @@ -16,6 +17,7 @@ type UserPreferencesViewModel = CraftCms.Cms.Http.ViewModels.UserPreferencesViewModel & { details?: string | null; + prefsHook?: CraftCms.Cms.View.HtmlFragment | null; }; interface UserPreferencesForm { @@ -318,7 +320,7 @@ - +