().props;
const form = useForm({
admin: props.user.admin,
diff --git a/resources/js/pages/users/Preferences.vue b/resources/js/pages/users/Preferences.vue
new file mode 100644
index 00000000000..3e812bd16d8
--- /dev/null
+++ b/resources/js/pages/users/Preferences.vue
@@ -0,0 +1,256 @@
+
+
+
+
+
+
+ {{ t('General') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('Accessibility') }}
+
+
+
+
+
+
+
+
+
+ {{ t('Notification Position') }}
+
+
+
+
+
+
+ - {{ form.errors.notificationPosition }}
+
+
+
+
+ {{ t('Slideout Position') }}
+
+
+
+
+
+
+ - {{ form.errors.slideoutPosition }}
+
+
+
+
+
+
+
+
+
+ {{ t('Development') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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/UserPermissionsViewModel.php b/src/Http/ViewModels/UserPermissionsViewModel.php
index 43d4f395468..d5e776a45b0 100644
--- a/src/Http/ViewModels/UserPermissionsViewModel.php
+++ b/src/Http/ViewModels/UserPermissionsViewModel.php
@@ -13,17 +13,18 @@
use CraftCms\Cms\User\Data\Permission;
use CraftCms\Cms\User\Data\UserGroup;
use CraftCms\Cms\User\Elements\User as UserElement;
-use Illuminate\Contracts\Support\Arrayable;
+use Illuminate\Support\Collection;
use function CraftCms\Cms\t;
-class UserPermissionsViewModel implements Arrayable
+class UserPermissionsViewModel extends ViewModel
{
- public bool $readOnly = false;
+ public function __construct(
+ private readonly UserElement $user,
+ private readonly CraftUser $currentUser,
+ ) {}
- public ?string $details = null;
-
- /** @var array{
+ /** @return array{
* id: int,
* username: string|null,
* admin: bool,
@@ -31,9 +32,18 @@ class UserPermissionsViewModel implements Arrayable
* isCredentialed: bool,
* }
*/
- public array $user;
+ public function user(): array
+ {
+ return [
+ 'id' => $this->user->id,
+ 'username' => $this->user->username,
+ 'admin' => $this->user->admin,
+ 'isCurrent' => $this->user->getIsCurrent(),
+ 'isCredentialed' => $this->user->getIsCredentialed(),
+ ];
+ }
- /** @var array
*/
- public array $groups;
+ public function groups(): array
+ {
+ return UserGroups::getAssignableGroups($this->user)
+ ->map(fn (UserGroup $group): array => [
+ 'id' => $group->id,
+ 'name' => $group->name,
+ 'handle' => $group->handle,
+ 'description' => $group->description,
+ 'permissions' => UserPermissions::getPermissionsByGroupId($group->id)->values()->all(),
+ ])
+ ->values()
+ ->all();
+ }
- /** @var int[] */
- public array $currentGroupIds;
+ /** @return int[] */
+ public function currentGroupIds(): array
+ {
+ return collect($this->user->getGroups())->pluck('id')->filter()->values()->all();
+ }
- /** @var array,
* handle: string,
* keys: string[],
* }>
*/
- public array $permissions;
-
- /** @var string[] */
- public array $directPermissions;
-
- /** @var string[] */
- public array $inheritedPermissions;
-
- public bool $showAdminSwitch;
-
- /** @var array{
- * allowAdminChanges: bool,
- * settingsUrl: string,
- * path: string[],
- * }|null
- */
- public ?array $teamPermissionsNotice;
-
- /** @var array{
- * assignUserPermissions: bool,
- * assignUserGroups: bool,
- * createGroups: bool,
- * canSendActivationEmail: bool,
- * }
- */
- public array $can;
-
- public function __construct(UserElement $user, CraftUser $currentUser)
+ public function permissions(): array
{
- $groupPermissions = $user->id
- ? UserPermissions::getGroupPermissionsByUserId($user->id)->values()
- : collect();
-
- $userPermissions = $user->id
- ? UserPermissions::getPermissionsByUserId($user->id)
- : collect();
-
- $this->user = [
- 'id' => $user->id,
- 'username' => $user->username,
- 'admin' => $user->admin,
- 'isCurrent' => $user->getIsCurrent(),
- 'isCredentialed' => $user->getIsCredentialed(),
- ];
+ return UserPermissions::getAssignablePermissions($this->user)->values()->toArray();
+ }
- $this->groups = UserGroups::getAssignableGroups($user)
- ->map(fn (UserGroup $group): array => [
- 'id' => $group->id,
- 'name' => $group->name,
- 'handle' => $group->handle,
- 'description' => $group->description,
- 'permissions' => UserPermissions::getPermissionsByGroupId($group->id)->values()->all(),
- ])
+ /** @return string[] */
+ public function directPermissions(): array
+ {
+ return $this->userPermissions()
+ ->diff($this->groupPermissions())
->values()
->all();
+ }
- $this->currentGroupIds = collect($user->getGroups())->pluck('id')->filter()->values()->all();
- $this->permissions = UserPermissions::getAssignablePermissions($user)->values()->toArray();
- $this->directPermissions = $userPermissions->diff($groupPermissions)->values()->all();
- $this->inheritedPermissions = $groupPermissions->all();
- $this->showAdminSwitch = $currentUser->isAdmin();
- $this->teamPermissionsNotice = $this->teamPermissionsNotice();
- $this->can = $this->permissionsAbilities($currentUser, $user);
+ /** @return string[] */
+ public function inheritedPermissions(): array
+ {
+ return $this->groupPermissions()->all();
}
- public function toArray(): array
+ public function showAdminSwitch(): bool
{
- return [
- 'readOnly' => $this->readOnly,
- 'details' => $this->details,
- 'user' => $this->user,
- 'groups' => $this->groups,
- 'currentGroupIds' => $this->currentGroupIds,
- 'permissions' => $this->permissions,
- 'directPermissions' => $this->directPermissions,
- 'inheritedPermissions' => $this->inheritedPermissions,
- 'showAdminSwitch' => $this->showAdminSwitch,
- 'teamPermissionsNotice' => $this->teamPermissionsNotice,
- 'can' => $this->can,
- ];
+ return $this->currentUser->isAdmin();
}
- private function teamPermissionsNotice(): ?array
+ /** @return array{
+ * allowAdminChanges: bool,
+ * settingsUrl: string,
+ * path: string[],
+ * }|null
+ */
+ public function teamPermissionsNotice(): ?array
{
if (Edition::get() !== Edition::Team) {
return null;
@@ -148,13 +122,34 @@ private function teamPermissionsNotice(): ?array
];
}
- private function permissionsAbilities(CraftUser $currentUser, UserElement $user): array
+ /** @return array{
+ * assignUserPermissions: bool,
+ * assignUserGroups: bool,
+ * createGroups: bool,
+ * canSendActivationEmail: bool,
+ * }
+ */
+ public function can(): array
{
return [
- 'assignUserPermissions' => $currentUser->can('assignUserPermissions'),
- 'assignUserGroups' => $currentUser->can('assignUserGroups', $user),
- 'createGroups' => $currentUser->isAdmin() && Cms::config()->allowAdminChanges && Edition::get()->value >= Edition::Pro->value,
- 'canSendActivationEmail' => ! $user->getIsCredentialed() && $user->username && $currentUser->can('sendActivationEmail', $user),
+ 'assignUserPermissions' => $this->currentUser->can('assignUserPermissions'),
+ 'assignUserGroups' => $this->currentUser->can('assignUserGroups', $this->user),
+ 'createGroups' => $this->currentUser->isAdmin() && Cms::config()->allowAdminChanges && Edition::get()->value >= Edition::Pro->value,
+ 'canSendActivationEmail' => ! $this->user->getIsCredentialed() && $this->user->username && $this->currentUser->can('sendActivationEmail', $this->user),
];
}
+
+ private function groupPermissions(): Collection
+ {
+ return $this->user->id
+ ? UserPermissions::getGroupPermissionsByUserId($this->user->id)->values()
+ : collect();
+ }
+
+ private function userPermissions(): Collection
+ {
+ return $this->user->id
+ ? UserPermissions::getPermissionsByUserId($this->user->id)
+ : collect();
+ }
}
diff --git a/src/Http/ViewModels/UserPreferencesViewModel.php b/src/Http/ViewModels/UserPreferencesViewModel.php
new file mode 100644
index 00000000000..2c0fd7ce365
--- /dev/null
+++ b/src/Http/ViewModels/UserPreferencesViewModel.php
@@ -0,0 +1,227 @@
+ */
+ public function preferences(): array
+ {
+ $a11yDefaults = Cms::config()->accessibilityDefaults;
+
+ return [
+ 'preferredLanguage' => $this->userLanguage(),
+ 'preferredLocale' => $this->userLocale(),
+ 'weekStartDay' => $this->user->getPreference('weekStartDay', Cms::config()->defaultWeekStartDay),
+ 'timeZone' => $this->user->getPreference('timeZone'),
+ 'useShapes' => $this->user->getPreference('useShapes') ?? $a11yDefaults['useShapes'] ?? false,
+ 'underlineLinks' => $this->user->getPreference('underlineLinks') ?? $a11yDefaults['underlineLinks'] ?? false,
+ 'disableAutofocus' => $this->user->getPreference('disableAutofocus') ?? $a11yDefaults['disableAutofocus'] ?? false,
+ 'notificationDuration' => $this->user->getPreference('notificationDuration') ?? $a11yDefaults['notificationDuration'] ?? 5000,
+ 'notificationPosition' => $this->user->getPreference('notificationPosition') ?? $a11yDefaults['notificationPosition'] ?? 'end-start',
+ 'slideoutPosition' => $this->user->getPreference('slideoutPosition') ?? $a11yDefaults['slideoutPosition'] ?? 'end',
+ 'showFieldHandles' => $this->user->getPreference('showFieldHandles') ?? false,
+ 'showExceptionView' => $this->user->getPreference('showExceptionView') ?? false,
+ 'profileTemplates' => $this->user->getPreference('profileTemplates') ?? false,
+ ];
+ }
+
+ /** @return array}> */
+ public function languageOptions(): array
+ {
+ return SelectOptions::getLanguageOptions(showLocalizedNames: true, appLocales: true);
+ }
+
+ /** @return array}> */
+ public function localeOptions(): array
+ {
+ return [
+ ['label' => t('Same as language'), 'value' => '__blank__'],
+ ...SelectOptions::getLanguageOptions(showLocalizedNames: true),
+ ];
+ }
+
+ public function isAdmin(): bool
+ {
+ return $this->user->isAdmin();
+ }
+
+ public function prefsHook(): ?HtmlFragment
+ {
+ $context = [
+ ...app(TemplateGlobals::class)->resolve(),
+ 'user' => $this->user,
+ 'isNewUser' => false,
+ ];
+
+ $fragment = HtmlStack::capture(fn (): string => app(TemplateHooks::class)->invoke('cp.users.edit.prefs', $context));
+
+ return $fragment->isEmpty() ? null : $fragment;
+ }
+
+ 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';
+ }
+
+ /** @return array */
+ public function weekStartDayOptions(): array
+ {
+ return collect(I18N::getLocale()->getWeekDayNames())
+ ->map(fn (string $label, int $value): array => [
+ 'label' => $label,
+ 'value' => (string) $value,
+ ])
+ ->values()
+ ->all();
+ }
+
+ /** @return array|null}> */
+ public function timeZoneOptions(): array
+ {
+ return [
+ [
+ 'label' => t('System time zone ({abbr})', ['abbr' => $this->systemTimeZoneAbbr()]),
+ 'value' => '__blank__',
+ ],
+ ...SelectOptions::getTimeZoneOptions(),
+ ];
+ }
+
+ /** @return array */
+ public function notificationDurationOptions(): array
+ {
+ return [
+ ['label' => t('{num, number} seconds', ['num' => 2]), 'value' => '2000'],
+ ['label' => t('{num, number} seconds', ['num' => 5]), 'value' => '5000'],
+ ['label' => t('{num, number} seconds', ['num' => 10]), 'value' => '10000'],
+ ['label' => t('Show them indefinitely'), 'value' => '0'],
+ ];
+ }
+
+ /** @return array */
+ public function displaySettingOptions(): array
+ {
+ return [
+ ['label' => t('Use shapes to represent statuses'), 'value' => 'useShapes'],
+ ['label' => t('Underline links'), 'value' => 'underlineLinks'],
+ ];
+ }
+
+ /** @return array */
+ public function generalSettingOptions(): array
+ {
+ return [
+ ['label' => t('Disable autofocus'), 'value' => 'disableAutofocus'],
+ ];
+ }
+
+ /** @return array */
+ public function developmentSettingOptions(): array
+ {
+ return [
+ ['label' => t('Show field handles in edit forms'), 'value' => 'showFieldHandles'],
+ ['label' => t('Profile Twig templates when Dev Mode is disabled'), 'value' => 'profileTemplates'],
+ ['label' => t('Show full exception views when Dev Mode is disabled'), 'value' => 'showExceptionView'],
+ ];
+ }
+
+ /** @return array */
+ public function notificationPositionOptions(): array
+ {
+ $orientation = I18N::getLocale()->getOrientation();
+
+ return [
+ [
+ 'icon' => $orientation === 'ltr' ? 'custom-icons/notification-top-left' : 'custom-icons/notification-top-right',
+ 'label' => $orientation === 'ltr' ? t('Top-Left') : t('Top-Right'),
+ 'value' => 'start-start',
+ ],
+ [
+ 'icon' => $orientation === 'ltr' ? 'custom-icons/notification-top-right' : 'custom-icons/notification-top-left',
+ 'label' => $orientation === 'ltr' ? t('Top-Right') : t('Top-Left'),
+ 'value' => 'start-end',
+ ],
+ [
+ 'icon' => $orientation === 'ltr' ? 'custom-icons/notification-bottom-left' : 'custom-icons/notification-bottom-right',
+ 'label' => $orientation === 'ltr' ? t('Bottom-Left') : t('Bottom-Right'),
+ 'value' => 'end-start',
+ ],
+ [
+ 'icon' => $orientation === 'ltr' ? 'custom-icons/notification-bottom-right' : 'custom-icons/notification-bottom-left',
+ 'label' => $orientation === 'ltr' ? t('Bottom-Right') : t('Bottom-Left'),
+ 'value' => 'end-end',
+ ],
+ ];
+ }
+
+ /** @return array */
+ public function slideoutPositionOptions(): array
+ {
+ $orientation = I18N::getLocale()->getOrientation();
+
+ return [
+ [
+ 'icon' => $orientation === 'ltr' ? 'custom-icons/slideout-left' : 'custom-icons/slideout-right',
+ 'label' => $orientation === 'ltr' ? t('Left') : t('Right'),
+ 'value' => 'start',
+ ],
+ [
+ 'icon' => $orientation === 'ltr' ? 'custom-icons/slideout-right' : 'custom-icons/slideout-left',
+ 'label' => $orientation === 'ltr' ? t('Right') : t('Left'),
+ 'value' => 'end',
+ ],
+ ];
+ }
+}
diff --git a/src/Http/ViewModels/ViewModel.php b/src/Http/ViewModels/ViewModel.php
new file mode 100644
index 00000000000..ef952921aa1
--- /dev/null
+++ b/src/Http/ViewModels/ViewModel.php
@@ -0,0 +1,41 @@
+publicMethodValues(),
+ ];
+ }
+
+ private function publicMethodValues(): array
+ {
+ return collect(new ReflectionClass($this)->getMethods(ReflectionMethod::IS_PUBLIC))
+ ->filter(fn (ReflectionMethod $method): bool => $method->getDeclaringClass()->getName() !== self::class
+ && ! $method->isConstructor()
+ && ! $method->isStatic())
+ ->mapWithKeys(fn (ReflectionMethod $method): array => [
+ $method->getName() => $method->invoke($this),
+ ])
+ ->all();
+ }
+}
diff --git a/src/Support/Facades/HtmlStack.php b/src/Support/Facades/HtmlStack.php
index ab4b8a69275..ea724485259 100644
--- a/src/Support/Facades/HtmlStack.php
+++ b/src/Support/Facades/HtmlStack.php
@@ -23,6 +23,7 @@
* @method static string bodyHtml(bool $clear = true)
* @method static string bodyBeginHtml(bool $clear = true)
* @method static string bodyEndHtml(bool $clear = true)
+ * @method static \CraftCms\Cms\View\HtmlFragment capture(callable $render)
* @method static void startBuffer(array|string $keys)
* @method static mixed clearBuffer(array|string $keys)
* @method static void startCssBuffer()
diff --git a/src/View/HtmlFragment.php b/src/View/HtmlFragment.php
new file mode 100644
index 00000000000..d02355b607d
--- /dev/null
+++ b/src/View/HtmlFragment.php
@@ -0,0 +1,38 @@
+html === ''
+ && $this->headHtml === ''
+ && $this->bodyHtml === '';
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'html' => $this->html,
+ 'headHtml' => $this->headHtml,
+ 'bodyHtml' => $this->bodyHtml,
+ ];
+ }
+
+ public function jsonSerialize(): array
+ {
+ return $this->toArray();
+ }
+}
diff --git a/src/View/HtmlStack.php b/src/View/HtmlStack.php
index bc401935523..45669cc36b3 100644
--- a/src/View/HtmlStack.php
+++ b/src/View/HtmlStack.php
@@ -26,6 +26,19 @@
#[Scoped]
class HtmlStack
{
+ private const array FragmentBufferKeys = [
+ 'js',
+ 'scripts',
+ 'jsFiles',
+ 'cssFiles',
+ 'css',
+ 'html',
+ 'jsImports',
+ 'metaTags',
+ 'linkTags',
+ 'icons',
+ ];
+
/** @var array> */
private array $js = [];
@@ -65,6 +78,8 @@ class HtmlStack
*/
private array $buffers = [];
+ private bool $suppressAssetRenderingEvents = false;
+
/**
* Registers inline JavaScript code.
*
@@ -276,7 +291,7 @@ public function linkTag(array $attributes, ?string $key = null): void
*/
public function headHtml(bool $clear = true): string
{
- event(new ViewAssetsRendering);
+ $this->dispatchAssetsRenderingEvent();
$head = Position::Head->value;
@@ -332,7 +347,7 @@ public function bodyHtml(bool $clear = true): string
*/
public function bodyBeginHtml(bool $clear = true): string
{
- event(new ViewAssetsRendering);
+ $this->dispatchAssetsRenderingEvent();
$body = Position::BodyBegin->value;
@@ -358,7 +373,7 @@ public function bodyBeginHtml(bool $clear = true): string
*/
public function bodyEndHtml(bool $clear = true): string
{
- event(new ViewAssetsRendering);
+ $this->dispatchAssetsRenderingEvent();
$body = Position::BodyEnd->value;
@@ -389,6 +404,28 @@ public function bodyEndHtml(bool $clear = true): string
return $parts->implode(PHP_EOL);
}
+ public function capture(callable $render): HtmlFragment
+ {
+ $this->dispatchAssetsRenderingEvent();
+ $this->startBuffer(self::FragmentBufferKeys);
+ $wasSuppressingAssetRenderingEvents = $this->suppressAssetRenderingEvents;
+
+ try {
+ $html = (string) $render();
+ $this->dispatchAssetsRenderingEvent();
+ $this->suppressAssetRenderingEvents = true;
+
+ return new HtmlFragment(
+ $html,
+ $this->headHtml(),
+ $this->bodyHtml(),
+ );
+ } finally {
+ $this->suppressAssetRenderingEvents = $wasSuppressingAssetRenderingEvents;
+ $this->clearBuffer(self::FragmentBufferKeys);
+ }
+ }
+
/**
* Begins buffering one or more asset property keys.
*
@@ -756,6 +793,15 @@ private function getAssetsForPosition(Position $position): Collection
->map(fn (string|Stringable $part) => (string) $part);
}
+ private function dispatchAssetsRenderingEvent(): void
+ {
+ if ($this->suppressAssetRenderingEvents) {
+ return;
+ }
+
+ event(new ViewAssetsRendering);
+ }
+
private function readyJs(): string
{
$js = implode(PHP_EOL, $this->js[Position::Ready->value] ?? []);
diff --git a/tests/Feature/Http/Controllers/User/PreferencesControllerTest.php b/tests/Feature/Http/Controllers/User/PreferencesControllerTest.php
index 2690f55f043..654cb5f6037 100644
--- a/tests/Feature/Http/Controllers/User/PreferencesControllerTest.php
+++ b/tests/Feature/Http/Controllers/User/PreferencesControllerTest.php
@@ -1,14 +1,17 @@
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()
+ ->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')
+ ->where('prefsHook', null));
+});
+
+test('index includes preferences hook output and assets', function () {
+ app(TemplateHooks::class)->register('cp.users.edit.prefs', function (array &$context, bool &$handled): string {
+ expect($context['user'])->toBeInstanceOf(User::class)
+ ->and($context['isNewUser'])->toBeFalse();
+
+ HtmlStack::cssFile('/prefs-hook.css');
+ HtmlStack::js('window.prefsHookReady = true');
+
+ return 'Preferences hook
';
+ });
+
+ get(cp_url('myaccount/preferences'))
->assertOk()
- ->assertSee(t('Preferences'));
+ ->assertInertia(fn (AssertableInertia $page) => $page
+ ->component('users/Preferences')
+ ->where('prefsHook.html', 'Preferences hook
')
+ ->where('prefsHook.headHtml', fn (string $html): bool => str_contains($html, '/prefs-hook.css'))
+ ->where('prefsHook.bodyHtml', fn (string $html): bool => str_contains($html, 'window.prefsHookReady = true')));
});
-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/tests/Unit/Http/ViewModelTest.php b/tests/Unit/Http/ViewModelTest.php
new file mode 100644
index 00000000000..7c82fdeec71
--- /dev/null
+++ b/tests/Unit/Http/ViewModelTest.php
@@ -0,0 +1,36 @@
+secret === 'hidden') {
+ return 'ready';
+ }
+
+ return 'not-ready';
+ }
+
+ public static function ignored(): string
+ {
+ return 'ignored';
+ }
+ };
+
+ expect($viewModel->toArray())->toBe([
+ 'name' => 'Craft',
+ 'items' => [],
+ 'status' => 'ready',
+ ]);
+});
diff --git a/tests/Unit/View/HtmlStackCaptureTest.php b/tests/Unit/View/HtmlStackCaptureTest.php
new file mode 100644
index 00000000000..290862cf35f
--- /dev/null
+++ b/tests/Unit/View/HtmlStackCaptureTest.php
@@ -0,0 +1,131 @@
+jsFile('/pre-capture-fragment.js', ['position' => Position::Head->value]);
+ }
+}
+
+class TestCapturedFragmentAsset implements LegacyAssetInterface
+{
+ public array $depends = [];
+
+ public function register(HtmlStack $htmlStack): void
+ {
+ $htmlStack->js('window.capturedFragmentAsset = true;');
+ }
+}
+
+beforeEach(function () {
+ app()->forgetScopedInstances();
+
+ $this->htmlStack = app(HtmlStack::class);
+});
+
+it('serializes html fragments', function () {
+ $fragment = new HtmlFragment(
+ 'Hook
',
+ '',
+ '',
+ );
+
+ expect($fragment->isEmpty())->toBeFalse()
+ ->and($fragment->toArray())->toBe([
+ 'html' => 'Hook
',
+ 'headHtml' => '',
+ 'bodyHtml' => '',
+ ])
+ ->and($fragment->jsonSerialize())->toBe($fragment->toArray())
+ ->and(new HtmlFragment()->isEmpty())->toBeTrue();
+});
+
+it('captures html with registered head and body assets', function () {
+ $fragment = $this->htmlStack->capture(function (): string {
+ $this->htmlStack->css('.prefs-hook { color: red; }');
+ $this->htmlStack->jsFile('/prefs-hook.js');
+ $this->htmlStack->js('window.prefsHookReady = true');
+
+ return 'Preferences hook
';
+ });
+
+ expect($fragment->html)->toBe('Preferences hook
')
+ ->and($fragment->headHtml)->toContain('.prefs-hook { color: red; }')
+ ->and($fragment->bodyHtml)->toContain('/prefs-hook.js')
+ ->and($fragment->bodyHtml)->toContain('window.prefsHookReady = true')
+ ->and($this->htmlStack->headHtml())->toBe('')
+ ->and($this->htmlStack->bodyHtml())->toBe('');
+});
+
+it('isolates captured assets from the outer stack', function () {
+ $this->htmlStack->cssFile('/outer.css');
+
+ $fragment = $this->htmlStack->capture(function (): string {
+ $this->htmlStack->cssFile('/inner.css');
+
+ return '';
+ });
+
+ $outerHeadHtml = $this->htmlStack->headHtml();
+
+ expect($fragment->headHtml)->toContain('/inner.css')
+ ->and($fragment->headHtml)->not->toContain('/outer.css')
+ ->and($outerHeadHtml)->toContain('/outer.css')
+ ->and($outerHeadHtml)->not->toContain('/inner.css');
+});
+
+it('supports nested captures', function () {
+ $inner = null;
+
+ $outer = $this->htmlStack->capture(function () use (&$inner): string {
+ $this->htmlStack->cssFile('/outer-captured.css');
+
+ $inner = $this->htmlStack->capture(function (): string {
+ $this->htmlStack->cssFile('/inner-captured.css');
+
+ return 'Inner
';
+ });
+
+ return 'Outer
';
+ });
+
+ expect($inner)->toBeInstanceOf(HtmlFragment::class)
+ ->and($inner->html)->toBe('Inner
')
+ ->and($inner->headHtml)->toContain('/inner-captured.css')
+ ->and($inner->headHtml)->not->toContain('/outer-captured.css')
+ ->and($outer->html)->toBe('Outer
')
+ ->and($outer->headHtml)->toContain('/outer-captured.css')
+ ->and($outer->headHtml)->not->toContain('/inner-captured.css');
+});
+
+it('keeps preexisting pending legacy assets outside the fragment', function () {
+ app(InternalAssetRegistry::class)->register(TestPreCaptureFragmentAsset::class);
+
+ $fragment = $this->htmlStack->capture(fn (): string => '');
+ $outerHeadHtml = $this->htmlStack->headHtml();
+
+ expect($fragment->isEmpty())->toBeTrue()
+ ->and($outerHeadHtml)->toContain('/pre-capture-fragment.js');
+});
+
+it('captures pending legacy assets registered during render', function () {
+ $fragment = $this->htmlStack->capture(function (): string {
+ app(InternalAssetRegistry::class)->register(TestCapturedFragmentAsset::class);
+
+ return '';
+ });
+
+ expect($fragment->bodyHtml)->toContain('window.capturedFragmentAsset = true')
+ ->and($this->htmlStack->bodyHtml())->toBe('');
+});
diff --git a/workbench/app/Providers/TypeScriptTransformerServiceProvider.php b/workbench/app/Providers/TypeScriptTransformerServiceProvider.php
index d03fef8f1f5..23aaa65baac 100644
--- a/workbench/app/Providers/TypeScriptTransformerServiceProvider.php
+++ b/workbench/app/Providers/TypeScriptTransformerServiceProvider.php
@@ -10,12 +10,14 @@
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;
use CraftCms\Cms\User\Data\Permission;
use CraftCms\Cms\User\Data\PermissionGroup;
use CraftCms\Cms\User\Data\UserSettings;
+use CraftCms\Cms\View\HtmlFragment;
use DateTimeInterface;
use Spatie\LaravelTypeScriptTransformer\TypeScriptTransformerApplicationServiceProvider;
use Spatie\TypeScriptTransformer\Transformers\EnumTransformer;
@@ -23,6 +25,7 @@
use Spatie\TypeScriptTransformer\Writers\GlobalNamespaceWriter;
use Workbench\App\TypeScript\ClassListClassTransformer;
use Workbench\App\TypeScript\ClassListTransformedProvider;
+use Workbench\App\TypeScript\ViewModelTransformer;
class TypeScriptTransformerServiceProvider extends TypeScriptTransformerApplicationServiceProvider
{
@@ -44,11 +47,14 @@ protected function configure(TypeScriptTransformerConfigFactory $config): void
PermissionGroup::class,
Route::class,
Updates::class,
+ HtmlFragment::class,
UserPermissionsViewModel::class,
+ UserPreferencesViewModel::class,
UserSettings::class,
],
[
new EnumTransformer,
+ new ViewModelTransformer,
new ClassListClassTransformer,
],
));
diff --git a/workbench/app/TypeScript/ViewModelTransformer.php b/workbench/app/TypeScript/ViewModelTransformer.php
new file mode 100644
index 00000000000..defb55e0b06
--- /dev/null
+++ b/workbench/app/TypeScript/ViewModelTransformer.php
@@ -0,0 +1,79 @@
+reflection->getName(), ViewModel::class);
+ }
+
+ protected function getTypeScriptNode(
+ PhpClassNode $phpClassNode,
+ TransformationContext $context,
+ ?ParsedClass $parsedClass = null,
+ ): TypeScriptNode {
+ $typeScriptNode = parent::getTypeScriptNode($phpClassNode, $context, $parsedClass);
+
+ if (! $typeScriptNode instanceof TypeScriptObject) {
+ return $typeScriptNode;
+ }
+
+ return new TypeScriptObject([
+ ...$typeScriptNode->properties,
+ ...array_map(
+ fn (PhpMethodNode $method): TypeScriptProperty => new TypeScriptProperty(
+ $method->getName(),
+ $this->resolveViewModelMethodType($phpClassNode, $method, $parsedClass),
+ ),
+ $this->getViewModelMethods($phpClassNode),
+ ),
+ ]);
+ }
+
+ /** @return array */
+ private function getViewModelMethods(PhpClassNode $phpClassNode): array
+ {
+ return array_filter(
+ $phpClassNode->getMethods(ReflectionMethod::IS_PUBLIC),
+ fn (PhpMethodNode $method): bool => $method->getDeclaringClass()->reflection->getName() === $phpClassNode->reflection->getName()
+ && count($method->getParameters()) === 0
+ && ! $method->reflection->isConstructor()
+ && ! $method->reflection->isStatic(),
+ );
+ }
+
+ private function resolveViewModelMethodType(
+ PhpClassNode $phpClassNode,
+ PhpMethodNode $method,
+ ?ParsedClass $parsedClass,
+ ): TypeScriptNode {
+ if ($returnType = $this->docTypeResolver->method($method)?->returnType) {
+ return $this->transpilePhpStanTypeToTypeScriptTypeAction->execute(
+ $returnType,
+ $phpClassNode,
+ $parsedClass->templates ?? [],
+ );
+ }
+
+ if ($returnType = $method->getReturnType()) {
+ return $this->transpilePhpTypeNodeToTypeScriptTypeAction->execute($returnType, $phpClassNode);
+ }
+
+ return new TypeScriptUnknown;
+ }
+}