From 809882b3616e84a83adc11152ea2b38876b768c7 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Mon, 6 Jul 2026 21:53:35 -0500 Subject: [PATCH 1/2] Port the new field page to Inertia + Vue Replaces the Twig-based settings/fields/new screen with an Inertia page (settings/fields/Edit) whose props are orchestrated by a FieldEditViewModel. - FieldsController::create() now returns an Inertia CpScreenResponse and honors a `type` query param so "Save and add another" preselects the last-saved type - Field type settings remain a server-rendered legacy HTML island, swapped via the existing fields/render-settings action and posted back to store() as a URL-encoded `typeSettings` string - store() accepts island settings via `typeSettings` alongside the legacy `types` array - Adds serializeFormInputs() for posting legacy HTML islands from Vue pages The edit screen for existing fields still uses the Twig path. Co-Authored-By: Claude Fable 5 --- .../js/common/utils/serializeFormInputs.ts | 47 +++ resources/js/pages/settings/fields/Edit.vue | 284 ++++++++++++++++++ src/Http/Controllers/FieldsController.php | 47 ++- src/Http/ViewModels/FieldEditViewModel.php | 151 ++++++++++ .../Http/Controllers/FieldsControllerTest.php | 45 ++- .../TypeScriptTransformerServiceProvider.php | 2 + 6 files changed, 569 insertions(+), 7 deletions(-) create mode 100644 resources/js/common/utils/serializeFormInputs.ts create mode 100644 resources/js/pages/settings/fields/Edit.vue create mode 100644 src/Http/ViewModels/FieldEditViewModel.php diff --git a/resources/js/common/utils/serializeFormInputs.ts b/resources/js/common/utils/serializeFormInputs.ts new file mode 100644 index 00000000000..83dbc2b9c6d --- /dev/null +++ b/resources/js/common/utils/serializeFormInputs.ts @@ -0,0 +1,47 @@ +/** + * Serializes every named form control inside a container into a URL-encoded + * string, mirroring jQuery's `.serialize()` semantics (unchecked checkboxes and + * radios, disabled controls, and buttons/files are omitted). + * + * Used to post legacy HTML islands — server-rendered inputs that aren't part of + * an Inertia form's state — back to endpoints that `parse_str()` them. + */ +export function serializeFormInputs(container: HTMLElement): string { + const params = new URLSearchParams(); + const controls = container.querySelectorAll< + HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement + >('input[name], select[name], textarea[name]'); + + for (const control of controls) { + if (control.disabled) { + continue; + } + + if (control instanceof HTMLInputElement) { + if ( + ['file', 'submit', 'button', 'reset', 'image'].includes(control.type) + ) { + continue; + } + + if ( + (control.type === 'checkbox' || control.type === 'radio') && + !control.checked + ) { + continue; + } + } + + if (control instanceof HTMLSelectElement && control.multiple) { + for (const option of control.selectedOptions) { + params.append(control.name, option.value); + } + + continue; + } + + params.append(control.name, control.value); + } + + return params.toString(); +} diff --git a/resources/js/pages/settings/fields/Edit.vue b/resources/js/pages/settings/fields/Edit.vue new file mode 100644 index 00000000000..b55a5ea8aa7 --- /dev/null +++ b/resources/js/pages/settings/fields/Edit.vue @@ -0,0 +1,284 @@ + + + diff --git a/src/Http/Controllers/FieldsController.php b/src/Http/Controllers/FieldsController.php index 26653b37e5a..0e0c4147d32 100644 --- a/src/Http/Controllers/FieldsController.php +++ b/src/Http/Controllers/FieldsController.php @@ -31,6 +31,7 @@ use CraftCms\Cms\Http\Requests\TableRequest; use CraftCms\Cms\Http\RespondsWithFlash; use CraftCms\Cms\Http\Responses\CpScreenResponse; +use CraftCms\Cms\Http\ViewModels\FieldEditViewModel; use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Facades\InputNamespace; use CraftCms\Cms\Support\Flash; @@ -90,11 +91,28 @@ public function index(TableRequest $request) ]); } - public function create(): CpScreenResponse + public function create(Request $request): CpScreenResponse { - $field = $this->fieldsService->createField(PlainText::class); + // "Save and add another" links back here with the last-saved type preselected + $type = $request->input('type'); + + if ( + ! is_string($type) || + ! ComponentHelper::validateComponentClass($type, FieldInterface::class) || + ! $type::isSelectable() + ) { + $type = PlainText::class; + } - return $this->cpScreenResponse($field); + /** @var Field $field */ + $field = $this->fieldsService->createField($type); + + return new CpScreenResponse() + ->title(t('Create a new field')) + ->addCrumb(t('Settings'), 'settings') + ->addCrumb(t('Fields'), 'settings/fields') + ->redirectUrl('settings/fields') + ->inertiaPage('settings/fields/Edit', new FieldEditViewModel($field, $this->fieldsService)); } public function edit(Request $request, ?FieldInterface $field = null, ?int $fieldId = null): CpScreenResponse @@ -102,7 +120,7 @@ public function edit(Request $request, ?FieldInterface $field = null, ?int $fiel $fieldId ??= $field->id ?? $request->input('fieldId'); if (is_null($fieldId)) { - return $this->create(); + return $this->create($request); } abort_if(is_null($found = $this->fieldsService->getFieldById((int) $fieldId)), 404, 'Field not found'); @@ -172,6 +190,7 @@ public function store(Request $request): Response|CpScreenResponse 'searchable' => ['nullable', 'boolean'], 'translationMethod' => ['nullable', 'string'], 'translationKeyFormat' => ['nullable', 'string'], + 'typeSettings' => ['nullable', 'string'], ]); $type = $request->input('type'); @@ -197,7 +216,7 @@ public function store(Request $request): Response|CpScreenResponse 'searchable' => (bool) $request->input('searchable', true), 'translationMethod' => $request->enum('translationMethod', TranslationMethod::class, TranslationMethod::None), 'translationKeyFormat' => $request->input('translationKeyFormat'), - 'settings' => $request->input('types', [])[Html::id($type)] ?? [], + 'settings' => $this->typeSettingsFromRequest($request, $type), ]); if (! $this->fieldsService->saveField($field)) { @@ -219,6 +238,24 @@ public function store(Request $request): Response|CpScreenResponse ], $redirect); } + /** + * The Inertia edit screen posts the field type settings island as a + * URL-encoded string (`typeSettings`), since its inputs are server-rendered + * HTML rather than form state. The legacy Twig form posts a `types` array. + */ + private function typeSettingsFromRequest(Request $request, string $type): array + { + $settingsStr = $request->input('typeSettings'); + + if (is_string($settingsStr) && $settingsStr !== '') { + parse_str($settingsStr, $postedSettings); + + return $postedSettings['types'][Html::id($type)] ?? []; + } + + return $request->input('types', [])[Html::id($type)] ?? []; + } + public function destroy(Request $request, int $fieldId): Response { /** @var FieldInterface|Field|null $field */ diff --git a/src/Http/ViewModels/FieldEditViewModel.php b/src/Http/ViewModels/FieldEditViewModel.php new file mode 100644 index 00000000000..ebab1395575 --- /dev/null +++ b/src/Http/ViewModels/FieldEditViewModel.php @@ -0,0 +1,151 @@ +, + * translationMethod: string, + * translationKeyFormat: string|null, + * } + */ + public function field(): array + { + return [ + 'id' => $this->field->id, + 'name' => $this->field->name, + 'handle' => $this->field->handle, + 'instructions' => $this->field->instructions, + 'searchable' => $this->field->searchable, + 'type' => $this->field::class, + 'translationMethod' => $this->field->translationMethodValue, + 'translationKeyFormat' => $this->field->translationKeyFormat, + ]; + } + + public function brandNew(): bool + { + return ! $this->field->id; + } + + /** @return array, + * label: string, + * icon: string|null, + * id: string, + * compatible: bool, + * }> + */ + public function fieldTypeOptions(): array + { + $allFieldTypes = $this->fieldsService->getAllFieldTypes(); + + $compatibleFieldTypes = $this->field->id + ? $this->fieldsService->getCompatibleFieldTypes($this->field, includeCurrent: true) + : $allFieldTypes; + + $currentType = $this->field instanceof MissingField + ? $this->field->expectedType + : $this->field::class; + + $options = []; + $names = []; + + foreach ($allFieldTypes as $class) { + $isCurrent = $class === $currentType; + + if (! $isCurrent && ! $class::isSelectable()) { + continue; + } + + $names[] = $name = $class::displayName(); + $options[] = [ + 'value' => $class, + 'label' => $name, + 'icon' => $isCurrent && $this->field instanceof Iconic + ? $this->field->getIcon() + : $class::icon(), + 'id' => Html::id($class), + 'compatible' => $isCurrent || $compatibleFieldTypes->contains($class), + ]; + } + + array_multisort($names, $options); + + return $options; + } + + /** @return array, string[]> */ + public function supportedTranslationMethods(): array + { + $currentType = $this->field::class; + + return $this->fieldsService->getAllFieldTypes() + ->filter(fn (string $class): bool => $class === $currentType || $class::isSelectable()) + ->mapWithKeys(fn (string $class): array => [ + $class => array_map( + static fn (TranslationMethod $method): string => $method->value, + $class::supportedTranslationMethods(), + ), + ]) + ->all(); + } + + /** @return array */ + public function translationMethodOptions(): array + { + return [ + ['value' => TranslationMethod::None->value, 'label' => t('Not translatable')], + ['value' => TranslationMethod::Site->value, 'label' => t('Translate for each site')], + ['value' => TranslationMethod::SiteGroup->value, 'label' => t('Translate for each site group')], + ['value' => TranslationMethod::Language->value, 'label' => t('Translate for each language')], + ['value' => TranslationMethod::Custom->value, 'label' => t('Custom…')], + ]; + } + + public function isMultiSite(): bool + { + return Sites::isMultiSite(); + } + + /** + * The current field type's settings, rendered as a legacy HTML island. + * Inputs are namespaced `types[]` to match what the save and + * render-settings endpoints expect. + */ + public function settings(): HtmlFragment + { + return HtmlStack::capture(fn (): string => template('settings/fields/_type-settings', [ + 'field' => $this->field, + 'namespace' => sprintf('types[%s]', Html::id($this->field::class)), + 'readOnly' => false, + ], templateMode: TemplateMode::Cp)); + } +} diff --git a/tests/Feature/Http/Controllers/FieldsControllerTest.php b/tests/Feature/Http/Controllers/FieldsControllerTest.php index af77288a6e8..f654778ca93 100644 --- a/tests/Feature/Http/Controllers/FieldsControllerTest.php +++ b/tests/Feature/Http/Controllers/FieldsControllerTest.php @@ -80,10 +80,29 @@ it('can create a new field', function () { $this->get(action([FieldsController::class, 'create'])) - ->assertSee('Create a new field') - ->assertSee('', false); + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('settings/fields/Edit') + ->where('title', 'Create a new field') + ->where('brandNew', true) + ->where('field.type', PlainText::class) + ->where('isMultiSite', fn ($value) => is_bool($value)) + ->has('fieldTypeOptions') + ->has('supportedTranslationMethods') + ->has('translationMethodOptions', 5) + ->has('settings.html')); }); +it('preselects a requested field type when creating', function (mixed $type, string $expectedType) { + $this->get(action([FieldsController::class, 'create'], ['type' => $type])) + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('settings/fields/Edit') + ->where('field.type', $expectedType)); +})->with([ + 'selectable type' => [RadioButtons::class, RadioButtons::class], + 'invalid class' => ['Not\\A\\Field', PlainText::class], + 'non-string' => [['array'], PlainText::class], +]); + it('404s when a field isn\'t found', function () { $this->get(action([FieldsController::class, 'edit'], ['fieldId' => 1])) ->assertNotFound(); @@ -144,6 +163,28 @@ }); }); +it('can save a new field with settings posted as a url-encoded string', function () { + $this->postJson(action([FieldsController::class, 'store']), [ + 'type' => PlainText::class, + 'name' => 'My plaintext field', + 'handle' => 'plainTextViaString', + 'typeSettings' => http_build_query([ + 'types' => [ + 'CraftCms-Cms-Field-PlainText' => [ + 'placeholder' => 'Type something…', + 'multiline' => '1', + ], + ], + ]), + ])->assertOk(); + + tap(FieldModel::query()->latest('id')->firstOrFail(), function (FieldModel $field) { + expect($field->handle)->toBe('plainTextViaString'); + expect($field->settings['placeholder'])->toBe('Type something…'); + expect($field->settings['multiline'])->toBeTrue(); + }); +}); + it('can delete a field', function () { Fields::saveField($field = Fields::createField([ 'type' => PlainText::class, diff --git a/workbench/app/Providers/TypeScriptTransformerServiceProvider.php b/workbench/app/Providers/TypeScriptTransformerServiceProvider.php index 23aaa65baac..d427af97a91 100644 --- a/workbench/app/Providers/TypeScriptTransformerServiceProvider.php +++ b/workbench/app/Providers/TypeScriptTransformerServiceProvider.php @@ -9,6 +9,7 @@ use CraftCms\Cms\Entry\Data\EntryTypeIndexData; use CraftCms\Cms\Gql\Data\GqlSchema; use CraftCms\Cms\Gql\Data\GqlToken; +use CraftCms\Cms\Http\ViewModels\FieldEditViewModel; use CraftCms\Cms\Http\ViewModels\UserPermissionsViewModel; use CraftCms\Cms\Http\ViewModels\UserPreferencesViewModel; use CraftCms\Cms\Image\Data\ImageTransform; @@ -48,6 +49,7 @@ protected function configure(TypeScriptTransformerConfigFactory $config): void Route::class, Updates::class, HtmlFragment::class, + FieldEditViewModel::class, UserPermissionsViewModel::class, UserPreferencesViewModel::class, UserSettings::class, From 64bbb5d59c4e7ab9ca3a62893dff71ec3d9e6713 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Tue, 7 Jul 2026 14:19:08 -0500 Subject: [PATCH 2/2] Port the field edit page to Inertia + Vue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the new-field Inertia port to existing fields. Full-page requests to settings/fields/edit/{id} (and fields/edit-field) now render the settings/fields/Edit page via FieldEditViewModel; slideout requests (Craft.CpScreenSlideout('fields/edit-field'), with or without a field ID) still get the legacy Twig screen as JSON — including the field layout designer's create-field slideout, which the initial port had broken. - FieldEditViewModel gains readOnly, metadataHtml, missingFieldPlaceholder, and missing-type handling (blank select option + placeholder, expected type used for options/settings/translation methods) - The "Used by" metadata pane moves to a shared Cp\Html\FieldHtml so the Inertia page and the slideout sidebar render the same HTML - The Vue page adopts the new default-layout convention (useAppLayout() instead of wrapping , which double-rendered the CP shell after the app-layout-parity merge), adds read-only input states, the metadata sidebar column, and a server-defined Delete action with confirm Co-Authored-By: Claude Fable 5 --- resources/js/pages/settings/fields/Edit.vue | 40 ++++- src/Cp/Html/FieldHtml.php | 126 +++++++++++++++ src/Http/Controllers/FieldsController.php | 148 +++++++----------- src/Http/ViewModels/FieldEditViewModel.php | 56 ++++++- .../Http/Controllers/FieldsControllerTest.php | 56 ++++++- 5 files changed, 317 insertions(+), 109 deletions(-) create mode 100644 src/Cp/Html/FieldHtml.php diff --git a/resources/js/pages/settings/fields/Edit.vue b/resources/js/pages/settings/fields/Edit.vue index b55a5ea8aa7..762740a4f33 100644 --- a/resources/js/pages/settings/fields/Edit.vue +++ b/resources/js/pages/settings/fields/Edit.vue @@ -1,5 +1,5 @@ diff --git a/src/Cp/Html/FieldHtml.php b/src/Cp/Html/FieldHtml.php new file mode 100644 index 00000000000..9d2e95fd3f4 --- /dev/null +++ b/src/Cp/Html/FieldHtml.php @@ -0,0 +1,126 @@ +contentHtml->metadataHtml([ + t('ID') => $field->id, + t('Used by') => fn () => $this->usagesHtml($field), + ]); + } + + private function usagesHtml(FieldInterface $field): string + { + $layouts = $this->fields->findFieldUsages($field); + + if ($layouts->isEmpty()) { + return Html::tag('i', t('No usages')); + } + + /** @var FieldLayout[][] $layoutsByType */ + $layoutsByType = $layouts + ->keyBy('uid') + ->groupBy(fn (FieldLayout $layout) => $layout->type ?? '__UNKNOWN__') + ->all(); + + /** @var FieldLayout[] $unknownLayouts */ + $unknownLayouts = Arr::pull($layoutsByType, '__UNKNOWN__'); + /** @var FieldLayout[] $layoutsWithProviders */ + $layoutsWithProviders = []; + + // re-fetch as many of these as we can from the element types, + // so they have a chance to supply the layout providers + foreach ($layoutsByType as $type => &$typeLayouts) { + /** @var class-string $type */ + /** @phpstan-ignore-next-line */ + foreach ($type::fieldLayouts(null) as $layout) { + if (isset($typeLayouts[$layout->uid]) && $layout->provider instanceof Chippable) { + $layoutsWithProviders[] = $layout; + unset($typeLayouts[$layout->uid]); + } + } + } + unset($typeLayouts); + + $labels = []; + $items = array_map(function (FieldLayout $layout) use (&$labels) { + /** @var FieldLayoutProviderInterface&Chippable $provider */ + $provider = $layout->provider; + $label = $labels[] = $provider->getUiLabel(); + $url = $provider instanceof CpEditable ? $provider->getCpEditUrl() : null; + $icon = $provider instanceof Iconic ? $provider->getIcon() : null; + + $labelHtml = Html::beginTag('span', [ + 'class' => ['flex', 'flex-nowrap', 'gap-s'], + ]); + if ($icon) { + $labelHtml .= Html::tag('div', Icons::svg($icon), [ + 'class' => array_filter([ + 'cp-icon', + 'small', + $provider instanceof Colorable ? $provider->getColor()?->value : null, + ]), + ]); + } + $labelHtml .= Html::tag('span', Html::encode($label)). + Html::endTag('span'); + + return $url ? Html::a($labelHtml, $url) : $labelHtml; + }, $layoutsWithProviders); + + // sort by label + array_multisort($labels, SORT_ASC, $items); + + foreach ($layoutsByType as $type => $typeLayouts) { + // any remaining layouts for this type? + if (! empty($typeLayouts)) { + /** @var class-string $type */ + $items[] = t('{total, number} {type} {total, plural, =1{field layout} other{field layouts}}', [ + 'total' => count($typeLayouts), + 'type' => $type::lowerDisplayName(), + ]); + } + } + + if (! empty($unknownLayouts)) { + $items[] = t('{total, number} {type} {total, plural, =1{field layout} other{field layouts}}', [ + 'total' => count($unknownLayouts), + 'type' => t('unknown'), + ]); + } + + $items = array_map(fn ($item) => Html::li($item)->encode(false), $items); + + return Html::ul()->items(...$items)->render(); + } +} diff --git a/src/Http/Controllers/FieldsController.php b/src/Http/Controllers/FieldsController.php index 0e0c4147d32..f2f16e3a995 100644 --- a/src/Http/Controllers/FieldsController.php +++ b/src/Http/Controllers/FieldsController.php @@ -5,16 +5,13 @@ namespace CraftCms\Cms\Http\Controllers; use CraftCms\Cms\Component\ComponentHelper; -use CraftCms\Cms\Component\Contracts\Chippable; -use CraftCms\Cms\Component\Contracts\Colorable; -use CraftCms\Cms\Component\Contracts\CpEditable; use CraftCms\Cms\Component\Contracts\Iconic; use CraftCms\Cms\Config\GeneralConfig; use CraftCms\Cms\Cp\FieldLayoutDesigner\CardDesigner; use CraftCms\Cms\Cp\FieldLayoutDesigner\FieldLayoutDesigner; use CraftCms\Cms\Cp\Html\ContentHtml; +use CraftCms\Cms\Cp\Html\FieldHtml; use CraftCms\Cms\Cp\Icons; -use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\Element\Validation\Rules\ElementTypeRule; use CraftCms\Cms\Field\Contracts\FieldInterface; use CraftCms\Cms\Field\Enums\TranslationMethod; @@ -22,8 +19,6 @@ use CraftCms\Cms\Field\Fields; use CraftCms\Cms\Field\MissingField; use CraftCms\Cms\Field\PlainText; -use CraftCms\Cms\FieldLayout\Contracts\FieldLayoutProviderInterface; -use CraftCms\Cms\FieldLayout\FieldLayout; use CraftCms\Cms\FieldLayout\FieldLayoutComponent; use CraftCms\Cms\FieldLayout\FieldLayoutElement; use CraftCms\Cms\FieldLayout\FieldLayoutTab; @@ -45,6 +40,7 @@ use Deprecated; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Crypt; use Illuminate\Validation\ValidationException; use Inertia\Inertia; use ReflectionException; @@ -93,6 +89,13 @@ public function index(TableRequest $request) public function create(Request $request): CpScreenResponse { + // Slideouts (`new Craft.CpScreenSlideout('fields/edit-field')` with no + // field ID, e.g. the field layout designer's "New field" button) still + // consume the legacy Twig screen as JSON. + if ($request->wantsJson()) { + return $this->cpScreenResponse($this->fieldsService->createField(PlainText::class), $request); + } + // "Save and add another" links back here with the last-saved type preselected $type = $request->input('type'); @@ -129,7 +132,51 @@ public function edit(Request $request, ?FieldInterface $field = null, ?int $fiel $field = $found; } - return $this->cpScreenResponse($field, $request); + if ($request->wantsJson()) { + return $this->cpScreenResponse($field, $request); + } + + return $this->editScreenResponse($field); + } + + private function editScreenResponse(FieldInterface $field): CpScreenResponse + { + // If the field's type class exists again (e.g. a plugin was reinstalled), + // swap the missing-field placeholder for a fresh instance of it + if ( + $field instanceof MissingField && + $this->fieldsService->getAllFieldTypes()->contains($field->expectedType) + ) { + $field = $this->fieldsService->createField($field->expectedType); + } + + /** @var Field $field */ + $response = new CpScreenResponse() + ->title(trim((string) $field->name) ?: t('Edit Field')) + ->addCrumb(t('Settings'), 'settings') + ->addCrumb(t('Fields'), 'settings/fields') + ->redirectUrl('settings/fields') + ->editUrl("settings/fields/edit/$field->id") + ->inertiaPage('settings/fields/Edit', new FieldEditViewModel($field, $this->fieldsService, $this->readOnly)); + + if (! $this->readOnly) { + $response->addAltAction(t('Delete'), [ + 'variant' => 'danger', + 'confirm' => t('Are you sure you want to delete “{name}”?', [ + 'name' => $field->name, + ]), + 'action' => [ + 'type' => 'http', + 'method' => 'DELETE', + 'url' => action([self::class, 'destroy'], ['fieldId' => $field->id]), + 'body' => [ + 'redirect' => Crypt::encrypt(action([self::class, 'index'])), + ], + ], + ]); + } + + return $response; } public function renderSettings(Request $request): JsonResponse @@ -593,92 +640,7 @@ private function cpScreenResponse(FieldInterface $field, ?Request $request = nul ]); } $response - ->metaSidebarHtml(app(ContentHtml::class)->metadataHtml([ - t('ID') => $field->id, - t('Used by') => function () use ($field) { - $layouts = $this->fieldsService->findFieldUsages($field); - - if ($layouts->isEmpty()) { - return Html::tag('i', t('No usages')); - } - - /** @var FieldLayout[][] $layoutsByType */ - $layoutsByType = $layouts - ->keyBy('uid') - ->groupBy(fn (FieldLayout $layout) => $layout->type ?? '__UNKNOWN__') - ->all(); - - /** @var FieldLayout[] $unknownLayouts */ - $unknownLayouts = Arr::pull($layoutsByType, '__UNKNOWN__'); - /** @var FieldLayout[] $layoutsWithProviders */ - $layoutsWithProviders = []; - - // re-fetch as many of these as we can from the element types, - // so they have a chance to supply the layout providers - foreach ($layoutsByType as $type => &$typeLayouts) { - /** @var class-string $type */ - /** @phpstan-ignore-next-line */ - foreach ($type::fieldLayouts(null) as $layout) { - if (isset($typeLayouts[$layout->uid]) && $layout->provider instanceof Chippable) { - $layoutsWithProviders[] = $layout; - unset($typeLayouts[$layout->uid]); - } - } - } - unset($typeLayouts); - - $labels = []; - $items = array_map(function (FieldLayout $layout) use (&$labels) { - /** @var FieldLayoutProviderInterface&Chippable $provider */ - $provider = $layout->provider; - $label = $labels[] = $provider->getUiLabel(); - $url = $provider instanceof CpEditable ? $provider->getCpEditUrl() : null; - $icon = $provider instanceof Iconic ? $provider->getIcon() : null; - - $labelHtml = Html::beginTag('span', [ - 'class' => ['flex', 'flex-nowrap', 'gap-s'], - ]); - if ($icon) { - $labelHtml .= Html::tag('div', Icons::svg($icon), [ - 'class' => array_filter([ - 'cp-icon', - 'small', - $provider instanceof Colorable ? $provider->getColor()?->value : null, - ]), - ]); - } - $labelHtml .= Html::tag('span', Html::encode($label)). - Html::endTag('span'); - - return $url ? Html::a($labelHtml, $url) : $labelHtml; - }, $layoutsWithProviders); - - // sort by label - array_multisort($labels, SORT_ASC, $items); - - foreach ($layoutsByType as $type => $typeLayouts) { - // any remaining layouts for this type? - if (! empty($typeLayouts)) { - /** @var class-string $type */ - $items[] = t('{total, number} {type} {total, plural, =1{field layout} other{field layouts}}', [ - 'total' => count($typeLayouts), - 'type' => $type::lowerDisplayName(), - ]); - } - } - - if (! empty($unknownLayouts)) { - $items[] = t('{total, number} {type} {total, plural, =1{field layout} other{field layouts}}', [ - 'total' => count($unknownLayouts), - 'type' => t('unknown'), - ]); - } - - $items = array_map(fn ($item) => Html::li($item)->encode(false), $items); - - return Html::ul()->items(...$items)->render(); - }, - ])); + ->metaSidebarHtml(app(FieldHtml::class)->metadataHtml($field)); } return $response; diff --git a/src/Http/ViewModels/FieldEditViewModel.php b/src/Http/ViewModels/FieldEditViewModel.php index ebab1395575..9679ba7acea 100644 --- a/src/Http/ViewModels/FieldEditViewModel.php +++ b/src/Http/ViewModels/FieldEditViewModel.php @@ -5,6 +5,7 @@ namespace CraftCms\Cms\Http\ViewModels; use CraftCms\Cms\Component\Contracts\Iconic; +use CraftCms\Cms\Cp\Html\FieldHtml; use CraftCms\Cms\Field\Enums\TranslationMethod; use CraftCms\Cms\Field\Field; use CraftCms\Cms\Field\Fields; @@ -23,6 +24,7 @@ class FieldEditViewModel extends ViewModel public function __construct( private readonly Field $field, private readonly Fields $fieldsService, + private readonly bool $readOnly = false, ) {} /** @return array{ @@ -44,7 +46,7 @@ public function field(): array 'handle' => $this->field->handle, 'instructions' => $this->field->instructions, 'searchable' => $this->field->searchable, - 'type' => $this->field::class, + 'type' => $this->typeClass(), 'translationMethod' => $this->field->translationMethodValue, 'translationKeyFormat' => $this->field->translationKeyFormat, ]; @@ -55,6 +57,11 @@ public function brandNew(): bool return ! $this->field->id; } + public function readOnly(): bool + { + return $this->readOnly; + } + /** @return array, * label: string, @@ -71,9 +78,7 @@ public function fieldTypeOptions(): array ? $this->fieldsService->getCompatibleFieldTypes($this->field, includeCurrent: true) : $allFieldTypes; - $currentType = $this->field instanceof MissingField - ? $this->field->expectedType - : $this->field::class; + $currentType = $this->typeClass(); $options = []; $names = []; @@ -99,13 +104,39 @@ public function fieldTypeOptions(): array array_multisort($names, $options); + // A missing type still needs a selectable (blank) option so the select + // reflects it until the user picks a real type + if ($this->field instanceof MissingField && ! in_array($currentType, array_column($options, 'value'), true)) { + array_unshift($options, [ + 'value' => $currentType, + 'label' => '', + 'icon' => null, + 'id' => Html::id($currentType), + 'compatible' => true, + ]); + } + return $options; } + public function missingFieldPlaceholder(): ?string + { + return $this->field instanceof MissingField + ? $this->field->getPlaceholderHtml() + : null; + } + + public function metadataHtml(): ?string + { + return $this->field->id + ? app(FieldHtml::class)->metadataHtml($this->field) + : null; + } + /** @return array, string[]> */ public function supportedTranslationMethods(): array { - $currentType = $this->field::class; + $currentType = $this->typeClass(); return $this->fieldsService->getAllFieldTypes() ->filter(fn (string $class): bool => $class === $currentType || $class::isSelectable()) @@ -144,8 +175,19 @@ public function settings(): HtmlFragment { return HtmlStack::capture(fn (): string => template('settings/fields/_type-settings', [ 'field' => $this->field, - 'namespace' => sprintf('types[%s]', Html::id($this->field::class)), - 'readOnly' => false, + 'namespace' => sprintf('types[%s]', Html::id($this->typeClass())), + 'readOnly' => $this->readOnly, ], templateMode: TemplateMode::Cp)); } + + /** + * @return class-string The type the field presents as — a missing + * field's expected type rather than MissingField itself. + */ + private function typeClass(): string + { + return $this->field instanceof MissingField + ? $this->field->expectedType + : $this->field::class; + } } diff --git a/tests/Feature/Http/Controllers/FieldsControllerTest.php b/tests/Feature/Http/Controllers/FieldsControllerTest.php index f654778ca93..994042b94ec 100644 --- a/tests/Feature/Http/Controllers/FieldsControllerTest.php +++ b/tests/Feature/Http/Controllers/FieldsControllerTest.php @@ -11,6 +11,7 @@ use CraftCms\Cms\Support\Facades\Fields; use CraftCms\Cms\Support\Str; use CraftCms\Cms\User\Elements\User; +use Illuminate\Testing\Fluent\AssertableJson; use Inertia\Testing\AssertableInertia; use function Pest\Laravel\actingAs; @@ -116,11 +117,60 @@ ])); $this->get(action([FieldsController::class, 'edit'], ['fieldId' => $field->id])) - ->assertOk() - ->assertSee('My plaintext field') - ->assertSee('plainText'); + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('settings/fields/Edit') + ->where('title', 'My plaintext field') + ->where('brandNew', false) + ->where('readOnly', false) + ->where('field.id', $field->id) + ->where('field.name', 'My plaintext field') + ->where('field.handle', 'plainText') + ->where('field.type', PlainText::class) + ->where('metadataHtml', fn ($value) => is_string($value) && $value !== '') + ->where('missingFieldPlaceholder', null) + ->has('settings.html')); +}); + +it('renders the edit screen read-only without admin changes', function () { + Fields::saveField($field = Fields::createField([ + 'type' => PlainText::class, + 'name' => 'My plaintext field', + 'handle' => 'plainText', + ])); + + Cms::config()->allowAdminChanges(false); + + $this->get(sprintf('/%s/settings/fields/edit/%d', Cms::config()->cpTrigger, $field->id)) + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('settings/fields/Edit') + ->where('readOnly', true)); }); +it('serves the legacy screen to slideout requests', function (?callable $setUp) { + $fieldId = $setUp ? $setUp()->id : null; + + $this->getJson( + action([FieldsController::class, 'edit'], array_filter(['fieldId' => $fieldId])), + ['X-Craft-Container-Id' => 'slideout'], + ) + ->assertOk() + ->assertJsonPath('action', 'fields/save-field') + ->assertJson(fn (AssertableJson $json) => $json + ->whereType('content', 'string') + ->etc()); +})->with([ + 'new field' => [null], + 'existing field' => [function () { + Fields::saveField($field = Fields::createField([ + 'type' => PlainText::class, + 'name' => 'My plaintext field', + 'handle' => 'plainText', + ])); + + return $field; + }], +]); + it('can render the settings of a field', function () { $this->postJson(action([FieldsController::class, 'renderSettings']), [ 'type' => PlainText::class,