diff --git a/.gitattributes b/.gitattributes index b934748bbaa..e2d5a73b749 100644 --- a/.gitattributes +++ b/.gitattributes @@ -40,6 +40,9 @@ # Ignore exporting the adapter as it will be split off and released on its own. /yii2-adapter export-ignore +# Workbench is only for development +/workbench export-ignore + # Identify generated files /resources/translations/a*/app.php linguist-generated=true /resources/translations/c*/app.php linguist-generated=true 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/common/layouts/AppLayout.vue b/resources/js/common/layouts/AppLayout.vue index 5c8f607053e..5b8cc6e5ff8 100644 --- a/resources/js/common/layouts/AppLayout.vue +++ b/resources/js/common/layouts/AppLayout.vue @@ -52,7 +52,7 @@ const page = usePage<{ title: string; - readOnly: boolean; + readOnly?: boolean; crumbs?: Array<{ url?: string; label: string; @@ -70,7 +70,7 @@ {label: t('Skip to main section'), url: '#main'}, ...(props.additionalSkipLinks ?? []), ]); - const readOnly = computed(() => page.props.readOnly); + const readOnly = computed(() => Boolean(page.props.readOnly)); const hasDetails = computed(() => Boolean(slots.details)); const sidebarToggle = useTemplateRef('sidebarToggle'); const primaryFormButton = 'primary'; @@ -153,9 +153,7 @@ } function isFormButtonProcessing(key: string) { - return ( - Boolean(props.form?.processing) && activeFormButton.value === key - ); + return Boolean(props.form?.processing) && activeFormButton.value === key; } function activateFormButton(key: string) { @@ -288,7 +286,9 @@ {{ t('Save') }} diff --git a/resources/js/pages/users/Permissions.vue b/resources/js/pages/users/Permissions.vue index e2338a580b1..593b56552cc 100644 --- a/resources/js/pages/users/Permissions.vue +++ b/resources/js/pages/users/Permissions.vue @@ -14,8 +14,12 @@ inheritAttrs: false, }); - const props = - usePage().props; + type UserPermissionsPageProps = + CraftCms.Cms.Http.ViewModels.UserPermissionsViewModel & { + details?: string | null; + }; + + const props = usePage().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 @@ + + + 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; + } +}