diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index 295e356acaa..d5f090218ba 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -12,6 +12,23 @@ - Removed support for the Debug Toolbar. [Laravel Debugbar](https://laraveldebugbar.com) can be used instead. ([#18812](https://github.com/craftcms/cms/pull/18812)) ### Extensibility +- Added `CraftCms\Cms\FieldLayout\FieldLayoutComponent::settingsForm()`. +- Added `CraftCms\Cms\FieldLayout\FieldLayoutComponent::settingsNodes()`. +- Added `CraftCms\Cms\FieldLayout\FieldLayoutComponent::conditionalSettingsNodes()`. +- Added `CraftCms\Cms\Form\Nodes\Action`. +- Added `CraftCms\Cms\Form\Nodes\Field::actions()`. +- Added `CraftCms\Cms\Form\Controls\Checkbox`. +- Added `CraftCms\Cms\Form\Controls\FieldSelect`. +- Added `CraftCms\Cms\Form\Controls\ConditionBuilder::fieldLayouts()`. +- Added `CraftCms\Cms\Cp\Components\Field::actions()`. +- Added `CraftCms\Cms\Cp\FormFields::fieldSelectHtml()`. +- Added the `actions` slot to ``. +- Deprecated `CraftCms\Cms\Cp\Components\Field::labelExtra()`. `actions()` should be used instead. +- Deprecated the `labelExtra` field config option. `actions` should be used instead. +- Deprecated ``’s `label-extra` slot. The `actions` slot should be used instead. +- Removed `CraftCms\Cms\FieldLayout\FieldLayoutComponent::settingsHtml()`. +- Removed `CraftCms\Cms\FieldLayout\FieldLayoutComponent::renderSettingsHtml()`. +- Removed `CraftCms\Cms\FieldLayout\FieldLayoutComponent::conditionalSettingsHtml()`. - Added `CraftCms\Cms\Support\Arr`. - Added `CraftCms\Cms\Support\DateTimeHelper`. - Added `CraftCms\Cms\Support\File`. diff --git a/docs/forms.md b/docs/forms.md index 538628d4b48..15f90f19494 100644 --- a/docs/forms.md +++ b/docs/forms.md @@ -126,6 +126,38 @@ Return zero or one root Node. It may contain children or a composite Control. Th mode. Listen for `FieldLayoutFormResolving` to add, remove, or reorder typed Nodes after compilation; do not mutate rendered HTML or persisted layout data. +### FieldLayout component settings + +Field layout components — tabs and layout elements — describe the form shown in the designer's settings slideout by +implementing `settingsNodes()` instead of `settingsHtml()`: + +```php +use CraftCms\Cms\Form\Controls\Text; +use CraftCms\Cms\Form\FormContext; +use CraftCms\Cms\Form\Nodes\Field; + +protected function settingsNodes(FormContext $context): array +{ + return [ + Field::make(t('Heading'), Text::make('heading')->value($this->heading)), + ]; +} +``` + +Return a list of Nodes with paths relative to the component's config, so a Control at `heading` posts back as the +component's `heading` setting. `FieldLayoutComponent::settingsForm()` is `final`: it composes `settingsNodes()` and +`conditionalSettingsNodes()`, separating them with a `Separator` Node when both are present, and returns `null` when +neither produces a Node. + +`conditionalSettingsNodes()` supplies the visibility condition builders. Override it to append further condition +groups — `CustomField` adds its editability conditions this way — and use `conditionGroupNode()` to build a group +with the standard user/element condition pair. + +The settings scope is `settings`, and the form is refreshable: a `discrete` change posts back to +`fields/refresh-layout-component-settings`, which rebuilds the component from the posted values and re-resolves the +form. Use that instead of client-side scripting when one setting should change another's state — hiding a field's +label, for instance, disables its label Control on the next refresh. + ## Custom Nodes and Controls Use a core Node or Control when one already has the required value shape and behavior. A plugin-specific type is needed @@ -143,6 +175,25 @@ A custom Node implements `CraftCms\Cms\Form\Contracts\Node`. A custom Control ca Container Nodes can extend `CraftCms\Cms\Form\Nodes\Container`, which provides stable UID storage, ordered children, fluent and conditional child addition, and the standard no-Control behavior. +### Field actions + +A `Field` Node can carry action Nodes in its heading, rendered into ``'s `actions` slot — a hide-label +toggle, a copy-value button, a settings menu: + +```php +use CraftCms\Cms\Form\Controls\Checkbox; +use CraftCms\Cms\Form\Nodes\Action; + +Field::make(t('Label'), Text::make('label')) + ->actions(Action::make( + Checkbox::make('labelHidden')->label(t('Hide')), + )); +``` + +Actions are resolved as ordinary child Nodes, so each one's Control gets its own path, value binding, mode, and error +binding — an action is a real posting Control, not decoration. `Action` renders its Control without the surrounding +field chrome. + Register the PHP types during plugin boot: ```php diff --git a/docs/slideouts.md b/docs/slideouts.md index a683c64598a..d39b666ea01 100644 --- a/docs/slideouts.md +++ b/docs/slideouts.md @@ -56,11 +56,33 @@ Fetches `href` as an Inertia page and mounts it in a panel. Returns the `Slideou Pass `opener` whenever you have it — focus restoration and nesting both depend on it. +### `openSlideoutWith(component, props?, options?)` + +Opens a panel around a component you supply, with no fetch. Same `options` as +`openSlideout`, and returns the `SlideoutInstance` synchronously — or `null` when +the user declined to discard a panel it would have replaced. + +Almost every screen lives at a URL and should use `openSlideout`. This is for the +ones that don't: the field layout designer builds its component settings by +POSTing the layout being edited, which is unsaved client state with nothing to +GET. + +```ts +openSlideoutWith(LayoutComponentSettings, {payload, apply}, {opener: button}); +``` + +The panel is otherwise ordinary — stacking, the shade, focus, Escape and the +unsaved-changes prompt all behave the same. `reload()` is a no-op, since there is +nothing to re-fetch. The component still renders inside `AppLayout`, so it +configures the shell with `useAppLayout()` like any page; pass a `form` to get a +Save button and an accurate dirty check. + ### Other exports ```ts import { openSlideout, + openSlideoutWith, closeSlideout, // (id: string) => void closeAllSlideouts, useSlideout, @@ -403,8 +425,15 @@ result props come back, and `rowSelection` is keyed by element id and lives outs ## Coexisting with the legacy stack The legacy `Craft.Slideout` (and its `CpScreenSlideout` / `ElementEditorSlideout` subclasses) is -still very much alive — the field layout designer, matrix, component select and the nested element -manager all open one. So both kinds can be on screen at once: a Vue panel opened over a legacy +still very much alive — matrix, component select and the nested element manager all open one, and +the field layout designer falls back to it. + +> The designer's component settings use `openSlideoutWith()` when the Vue stack is available, and +> the legacy slideout otherwise. It has to: `SlideoutHost` is only mounted by the Inertia CP shell, +> and the designer is also reachable from legacy-stack screens through the `fieldLayoutDesigner()` +> Twig function. `canUseVueSlideout()` in +> `resources/js/modules/field-layout-designer/settings-slideout.ts` decides, keying off whether the +> shell registered its globals. So both kinds can be on screen at once: a Vue panel opened over a legacy slideout, or a legacy element editor opened from inside a Vue panel. Everything that needs to see *all* open panels rather than one stack's own lives in diff --git a/package-lock.json b/package-lock.json index ef97df2b7e6..1e8633ff39a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5206,7 +5206,6 @@ }, "node_modules/@popperjs/core": { "version": "2.11.8", - "dev": true, "license": "MIT", "funding": { "type": "opencollective", diff --git a/packages/craftcms-ui/src/components/action-menu/action-menu.ts b/packages/craftcms-ui/src/components/action-menu/action-menu.ts index f1f9863803d..e05d7cd5791 100644 --- a/packages/craftcms-ui/src/components/action-menu/action-menu.ts +++ b/packages/craftcms-ui/src/components/action-menu/action-menu.ts @@ -522,8 +522,8 @@ export default class CraftActionMenu extends CraftPopover { invoker.setAttribute('type', 'button'); invoker.setAttribute('icon', ''); invoker.setAttribute('size', 'small'); - invoker.setAttribute('variant', 'inherit'); - invoker.setAttribute('appearance', 'plain'); + invoker.setAttribute('inherit', 'true'); + invoker.setAttribute('variant', 'plain'); this._generatedInvoker = invoker; this.appendChild(invoker); } @@ -574,9 +574,10 @@ export default class CraftActionMenu extends CraftPopover { // Once Lion's overlay controller is set up, the slotted content node is // moved inside its content wrapper (a ) and loses its `slot` // attribute — so prefer the node Lion has already resolved and cached. - if (this._cachedOverlayContentNode) { - return this._cachedOverlayContentNode; + if (this._overlayContentNode) { + return this._overlayContentNode; } + return ( (Array.from(this.children).find((child) => child.slot === 'content') as | HTMLElement diff --git a/packages/craftcms-ui/src/components/field/field.stories.ts b/packages/craftcms-ui/src/components/field/field.stories.ts index 5037efaec5b..2fe16a11159 100644 --- a/packages/craftcms-ui/src/components/field/field.stories.ts +++ b/packages/craftcms-ui/src/components/field/field.stories.ts @@ -142,6 +142,18 @@ export const LabelExtra: Story = { `, }; +export const Actions: Story = { + render: () => html` + + + + + Copy value + + + `, +}; + export const WithCraftInput: Story = { render: () => html` { }); }); +describe('craft-field actions', () => { + it('renders a flex-grow spacer before slotted actions', async () => { + const element = await createField( + {label: 'My field'}, + '' + ); + + const heading = element.shadowRoot!.querySelector('.heading')!; + const spacer = heading.querySelector('.flex-grow'); + const slot = heading.querySelector('slot[name="actions"]'); + expect(spacer).not.toBeNull(); + expect(slot).not.toBeNull(); + expect( + spacer!.compareDocumentPosition(slot!) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + }); + + it('groups slotted actions', async () => { + const element = await createField( + {label: 'My field'}, + '' + ); + + const group = element.shadowRoot!.querySelector('.field-actions')!; + expect(group).not.toBeNull(); + expect(group.getAttribute('role')).toBe('group'); + expect(group.querySelector('slot[name="actions"]')).not.toBeNull(); + }); + + it('renders actions after label extras', async () => { + const element = await createField( + {label: 'My field'}, + 'handle' + ); + + const heading = element.shadowRoot!.querySelector('.heading')!; + const labelExtra = heading.querySelector('slot[name="label-extra"]')!; + const actions = heading.querySelector('slot[name="actions"]')!; + expect( + labelExtra.compareDocumentPosition(actions) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + }); + + it('renders no action group without actions', async () => { + const element = await createField({label: 'My field'}); + expect(element.shadowRoot!.querySelector('.field-actions')).toBeNull(); + }); +}); + describe('craft-field disabled state', () => { it('adds the disabled class to the input container only', async () => { const element = await createField({label: 'My field', disabled: ''}); diff --git a/packages/craftcms-ui/src/components/field/field.ts b/packages/craftcms-ui/src/components/field/field.ts index f4f093a5b28..8626b4a1727 100644 --- a/packages/craftcms-ui/src/components/field/field.ts +++ b/packages/craftcms-ui/src/components/field/field.ts @@ -38,8 +38,10 @@ type FormControlTarget = HTMLElement & { * @slot feedback - Validation errors (e.g. an error list). * @slot tip - Tip notice content, rendered inside an info callout. * @slot warning - Warning notice content, rendered inside a warning callout. - * @slot label-extra - Extra heading content (handle-copy buttons, action - * menus), rendered after a flex-grow spacer. + * @slot label-extra - Extra heading content, rendered after a flex-grow spacer. + * @deprecated Use `actions` instead. + * @slot actions - Field-level actions (hide-label toggles, copy-value buttons, + * field settings menus), rendered as a group at the end of the heading. */ export default class CraftField extends FormControlMixin(LitElement) { static override get styles() { @@ -291,10 +293,12 @@ export default class CraftField extends FormControlMixin(LitElement) { } /** - * The field heading: label, read-only badge, flex-grow spacer and label - * extras, mirroring `.field > .heading` in the Blade wrapper. + * The field heading: label, read-only badge, flex-grow spacer, label extras + * and actions, mirroring `.field > .heading` in the Blade wrapper. */ protected override _labelTemplate() { + const hasActions = this.__hasLightChild('actions'); + return html`
@@ -302,10 +306,22 @@ export default class CraftField extends FormControlMixin(LitElement) { ${this.readOnly ? html`${t('Read Only')}` : nothing} - ${this.__hasLightChild('label-extra') + ${this.__hasLightChild('label-extra') || hasActions ? html`
` : nothing} + ${hasActions + ? html` +
+ +
+ ` + : html``}
`; @@ -394,8 +410,8 @@ export default class CraftField extends FormControlMixin(LitElement) { this.__syncLabelDecorations(); this.__syncHasMaxlength(); this.__syncControlWidth(); - // Conditional templates (tip/warning callouts, label-extra spacer) depend - // on light DOM children. + // Conditional templates (tip/warning callouts, heading spacer, action + // group) depend on light DOM children. this.requestUpdate(); } diff --git a/packages/craftcms-ui/src/components/reorder-button/reorder-button.ts b/packages/craftcms-ui/src/components/reorder-button/reorder-button.ts index 6a5148062ab..b65e48f8117 100644 --- a/packages/craftcms-ui/src/components/reorder-button/reorder-button.ts +++ b/packages/craftcms-ui/src/components/reorder-button/reorder-button.ts @@ -50,7 +50,7 @@ export default class CraftReorderButton extends LitElement { @property({reflect: true}) orientation: ReorderOrientation = 'vertical'; /** Theme variant forwarded to the underlying invoker button. */ - @property({reflect: true}) variant: string = 'neutral'; + @property({reflect: true}) variant: string = 'plain'; /** * Disables the button: blocks pointer interaction (so it can't open the menu or @@ -125,7 +125,6 @@ export default class CraftReorderButton extends LitElement { type="button" icon size="small" - variant="plain" variant="${this.variant}" ?disabled="${this.disabled}" > diff --git a/packages/craftcms-ui/src/components/text-expander/text-expander.ts b/packages/craftcms-ui/src/components/text-expander/text-expander.ts index b3fd5a44cf8..2912b9714ea 100644 --- a/packages/craftcms-ui/src/components/text-expander/text-expander.ts +++ b/packages/craftcms-ui/src/components/text-expander/text-expander.ts @@ -422,7 +422,8 @@ export default class CraftTextExpander extends LitElement { return; } - const label = this.#listbox.getAttribute('aria-label') ?? defaultListboxLabel; + const label = + this.#listbox.getAttribute('aria-label') ?? defaultListboxLabel; this.#cancelPending(); this.#match = null; diff --git a/resources/css/fld.css b/resources/css/fld.css index c71eb193325..523f031ef2f 100644 --- a/resources/css/fld.css +++ b/resources/css/fld.css @@ -370,9 +370,19 @@ body:not(.dragging) .fld-element { } /* Element settings (slideout) */ + +/* Never taller than the viewport, so the body scrolls rather than the slideout + growing past the window. */ +.fld-element-settings { + max-height: 100dvh; +} + .fld-element-settings-body { flex: 1; - margin-block: -24px 0; + + /* Flex items default to min-height: auto, which refuses to shrink below the + content and stops `overflow` below from ever engaging. */ + min-height: 0; /* TODO: no token yet for --neg-padding */ margin-inline: var(--neg-padding); @@ -393,7 +403,6 @@ body:not(.dragging) .fld-element { display: flex; gap: var(--c-spacing-sm); flex-direction: row; - margin-block: 0 -24px; /* TODO: no token yet for --neg-padding */ margin-inline: var(--neg-padding); diff --git a/resources/js/common/layouts/screens/SlideoutScreen.vue b/resources/js/common/layouts/screens/SlideoutScreen.vue index 0a99904b66c..380870be927 100644 --- a/resources/js/common/layouts/screens/SlideoutScreen.vue +++ b/resources/js/common/layouts/screens/SlideoutScreen.vue @@ -418,7 +418,9 @@ - + + + diff --git a/resources/js/common/slideouts/SlideoutPanel.vue b/resources/js/common/slideouts/SlideoutPanel.vue index 9f32c8b7023..f182207a3fb 100644 --- a/resources/js/common/slideouts/SlideoutPanel.vue +++ b/resources/js/common/slideouts/SlideoutPanel.vue @@ -134,6 +134,12 @@ // After `addLayer`, which the stack relies on to work out which container // to leave visible to assistive technology. registerPanel(stackPanel); + + // A locally-built panel (see `openSlideoutWith`) arrives with its component + // already set, so the watcher below never fires for it. + if (props.instance.component) { + setFocusWithin(el); + } }); onBeforeUnmount(() => { diff --git a/resources/js/common/slideouts/index.ts b/resources/js/common/slideouts/index.ts index 541c158ca8a..efe45497d0e 100644 --- a/resources/js/common/slideouts/index.ts +++ b/resources/js/common/slideouts/index.ts @@ -2,7 +2,12 @@ import {closeAllSlideouts, closeSlideout, openSlideout} from './store'; export {default as SlideoutHost} from './SlideoutHost.vue'; export {useSlideout, useSlideoutOpener} from './useSlideout'; -export {closeAllSlideouts, closeSlideout, openSlideout} from './store'; +export { + closeAllSlideouts, + closeSlideout, + openSlideout, + openSlideoutWith, +} from './store'; export type { OpenSlideoutOptions, SlideoutController, diff --git a/resources/js/common/slideouts/slideouts.test.ts b/resources/js/common/slideouts/slideouts.test.ts index 2531210ca42..7c96fee1b72 100644 --- a/resources/js/common/slideouts/slideouts.test.ts +++ b/resources/js/common/slideouts/slideouts.test.ts @@ -42,6 +42,7 @@ const { closeSlideout, notifySlideoutSaved, openSlideout, + openSlideoutWith, setSlideoutDirtyCheck, slideoutPanels, } = await import('./store'); @@ -200,6 +201,74 @@ describe('slideout store', () => { }); }); +describe('locally-built panels', () => { + const Local = defineComponent({render: () => h('div', 'local')}); + + it('opens without fetching a screen', () => { + const panel = openSlideoutWith(Local as any, {foo: 'bar'}); + + expect(fetchSlideoutPage).not.toHaveBeenCalled(); + expect(panel).not.toBeNull(); + expect(panel!.component).toStrictEqual(Local); + expect(panel!.props).toEqual({foo: 'bar'}); + expect(panel!.loading).toBe(false); + expect(panel!.href).toBe(''); + }); + + it('stacks against fetched panels like any other', async () => { + fetchSlideoutPage.mockResolvedValue({ + component: {render: () => null}, + props: {}, + url: '/one', + }); + const first = await openSlideout('/one'); + openSlideoutWith( + Local as any, + {}, + { + opener: openerInPanel(first!.id), + } + ); + + expect(slideoutPanels()).toHaveLength(2); + }); + + it('replaces an existing panel when opened from the base page', async () => { + fetchSlideoutPage.mockResolvedValue({ + component: {render: () => null}, + props: {}, + url: '/one', + }); + await openSlideout('/one'); + openSlideoutWith(Local as any); + + expect(slideoutPanels()).toHaveLength(1); + }); + + it('honours the unsaved-changes prompt of the panel it replaces', async () => { + fetchSlideoutPage.mockResolvedValue({ + component: {render: () => null}, + props: {}, + url: '/one', + }); + const first = await openSlideout('/one'); + setSlideoutDirtyCheck(first!.id, () => true); + // happy-dom doesn't implement confirm(), so install one to spy on. + const confirmSpy = vi.fn(() => false); + Object.defineProperty(window, 'confirm', { + configurable: true, + writable: true, + value: confirmSpy, + }); + + const panel = openSlideoutWith(Local as any); + + expect(confirmSpy).toHaveBeenCalled(); + expect(panel).toBeNull(); + expect(slideoutPanels()).toHaveLength(1); + }); +}); + describe('reporting a save to the opener', () => { beforeEach(() => { fetchSlideoutPage.mockResolvedValue({ diff --git a/resources/js/common/slideouts/store.ts b/resources/js/common/slideouts/store.ts index 824c7c5e2c8..540d55ddf01 100644 --- a/resources/js/common/slideouts/store.ts +++ b/resources/js/common/slideouts/store.ts @@ -1,5 +1,6 @@ import {reactive, readonly, type DeepReadonly} from 'vue'; import {t} from '@craftcms/ui/utilities/translate'; +import type {InertiaPageComponent} from '@/bootstrap/inertia-pages'; import {fetchSlideoutPage} from './request'; import type { OpenSlideoutOptions, @@ -119,6 +120,53 @@ export async function openSlideout( return panel; } +/** + * Open a locally-built component in a slideout, without fetching a screen. + * + * `openSlideout()` covers the normal case — a CP screen that lives at a URL. + * Some panels have no URL to fetch: the field layout designer builds its + * settings form by POSTing the layout being edited, which is unsaved client + * state. Those supply the component and props themselves. + * + * The panel is otherwise identical, so stacking, the shade, focus handling, + * Escape and dirty-checking all behave the same. `reload()` is a no-op: there + * is nothing to re-fetch. + */ +export function openSlideoutWith( + component: InertiaPageComponent, + props: Record = {}, + options: OpenSlideoutOptions = {} +): SlideoutInstance | null { + const opener = + options.opener ?? + (document.activeElement instanceof HTMLElement + ? document.activeElement + : null); + + if (!closeAbove(originPanel(opener))) { + return null; + } + + const id = `slideout-${++nextId}`; + + const panel = reactive({ + id, + containerId: id, + href: '', + component, + props, + loading: false, + error: null, + opener, + onSaved: options.onSaved ?? null, + width: options.width ?? null, + }); + + panels.push(panel); + + return panel; +} + /** The panel an element lives in, or `null` if it's on the base page. */ function originPanel(opener: HTMLElement | null): string | null { return ( @@ -180,7 +228,8 @@ export function notifySlideoutSaved( export async function reloadSlideout(id: string): Promise { const panel = findSlideout(id); - if (panel) { + // Local panels (see openSlideoutWith) have nothing to re-fetch. + if (panel && panel.href) { await loadInto(panel); } } diff --git a/resources/js/cp.ts b/resources/js/cp.ts index c4aa09a6dd5..7ea40011048 100644 --- a/resources/js/cp.ts +++ b/resources/js/cp.ts @@ -2,6 +2,7 @@ import '@craftcms/ui'; import '../../packages/craftcms-legacy/cp/src/js/UI.js'; import Cp from './bootstrap/cp.js'; import {defineEntryFieldLayoutFormHost} from './modules/forms/entry-field-layout-form-host'; +import {defineLayoutComponentSettingsFormHost} from './modules/forms/layout-component-settings-form-host'; import './modules/navigation/components/cp-global-sidebar.js'; import './modules/navigation/components/cp-queue-indicator.js'; @@ -52,3 +53,4 @@ import './modules/ui'; window.Cp = Cp as unknown as typeof window.Cp; defineEntryFieldLayoutFormHost(Cp.$components); +defineLayoutComponentSettingsFormHost(Cp.$components); diff --git a/resources/js/legacy.ts b/resources/js/legacy.ts index 42b2346ab42..2a5f1a42e3f 100644 --- a/resources/js/legacy.ts +++ b/resources/js/legacy.ts @@ -12,6 +12,7 @@ import './modules/auth/components/recovery-codes/recovery-code-form.js'; import {mountElevatedSessionHost} from './modules/auth/elevated-session'; import {defineDashboardWidgetSettingsFormHost} from './modules/forms/dashboard-widget-settings-form-host'; import {defineEntryFieldLayoutFormHost} from './modules/forms/entry-field-layout-form-host'; +import {defineLayoutComponentSettingsFormHost} from './modules/forms/layout-component-settings-form-host'; import './modules/listbox/index'; import './modules/matrix/index'; @@ -65,6 +66,7 @@ Cp.config((window as any).Craft ?? {}); Cp.init(); defineDashboardWidgetSettingsFormHost(Cp.$components); defineEntryFieldLayoutFormHost(Cp.$components); +defineLayoutComponentSettingsFormHost(Cp.$components); mountElevatedSessionHost(); diff --git a/resources/js/modules/field-layout-designer/LayoutComponentSettings.vue b/resources/js/modules/field-layout-designer/LayoutComponentSettings.vue new file mode 100644 index 00000000000..b7c7bc64e37 --- /dev/null +++ b/resources/js/modules/field-layout-designer/LayoutComponentSettings.vue @@ -0,0 +1,130 @@ + + + diff --git a/resources/js/modules/field-layout-designer/element.ts b/resources/js/modules/field-layout-designer/element.ts index 45dbd4376a0..905b2615903 100644 --- a/resources/js/modules/field-layout-designer/element.ts +++ b/resources/js/modules/field-layout-designer/element.ts @@ -1,12 +1,16 @@ import {Base, hasAttr} from '@craftcms/garnish'; import {FieldLayoutDesigner} from './field-layout-designer'; +import { + canUseVueSlideout, + openLayoutComponentSettings, +} from './settings-slideout'; import { firstFocusableInSiblings, fldElementData, htmlToElement, } from './support'; import type {Tab} from './tab'; -import {serializeFormInputs, type ActionMenuItem} from '@craftcms/ui'; +import {type ActionMenuItem, t} from '@craftcms/ui'; declare const Craft: any; @@ -30,11 +34,9 @@ export class Element extends Base { thumbable = false; hasCustomWidth = false; hasSettings = false; - settingsNamespace: any = null; slideout: any = null; defaultHandle: any = null; fieldId: any = null; - fieldsWithErrors: any[] = []; constructor(tab: Tab, $container: any) { super(); @@ -43,8 +45,6 @@ export class Element extends Base { this.uid = $container.dataset.uid; this.fieldId = $container.dataset.id; - this.fieldsWithErrors = []; - // New element? const isNew = !this.uid; if (isNew) { @@ -256,6 +256,14 @@ export class Element extends Base { return label !== '' ? label : this.$container.dataset.attribute; } + private settingsRequestData(): Record { + return { + uid: this.uid, + layoutConfig: this.tab.designer.config, + elementType: this.tab.designer.settings!.elementType, + }; + } + async createSettings(): Promise { let data; try { @@ -264,9 +272,8 @@ export class Element extends Base { 'fields/render-layout-component-settings', { data: { - uid: this.uid, - layoutConfig: this.tab.designer.config, - elementType: this.tab.designer.settings!.elementType, + ...this.settingsRequestData(), + config: this.config, }, } ); @@ -276,9 +283,28 @@ export class Element extends Base { throw e; } - this.settingsNamespace = data.namespace; + const requestData = () => ({ + ...this.settingsRequestData(), + config: this.config, + }); + + if (canUseVueSlideout()) { + await openLayoutComponentSettings(data, { + title: this.settingsTitle(), + triggerElement: this.$actionBtn, + requestData, + // The panel owns Save/Cancel and reports errors from the rejection. + apply: (settings) => this.applyConfig(() => this.config, settings), + }); + + this.trigger('createSettings'); + + return; + } + this.slideout = await FieldLayoutDesigner.createSlideout(data, { triggerElement: this.$actionBtn, + requestData, }); // slideout.$container is a Craft jQuery object; bind on the native form. @@ -298,15 +324,6 @@ export class Element extends Base { this.refreshField((event as unknown as CustomEvent).detail.selectorHtml); }); - if (this.isField) { - const $handleInput = $fieldsContainer?.querySelector( - 'input[name$="[handle]"]' - ); - if ($handleInput) { - $handleInput.value = this.config.handle || ''; - } - } - this.trigger('createSettings'); } @@ -318,12 +335,26 @@ export class Element extends Base { $submitBtn?.classList.add('loading'); try { - await this.applyConfig(() => this.config, true); + await this.applyConfig( + () => this.config, + this.slideout.settingsForm?.currentValues() ?? {} + ); + } catch { + // Errors are already shown in the slideout. } finally { $submitBtn?.classList.remove('loading'); } } + /** The label shown in the settings panel's title bar. */ + private settingsTitle(): string { + return this.getLabel() + ? t('{label} Settings', { + label: this.getLabel(), + }) + : t('Settings'); + } + async showFieldEditor(): Promise { const slideout = new Craft.CpScreenSlideout( Craft.getCpUrl('settings/fields/edit'), @@ -388,7 +419,7 @@ export class Element extends Base { async applyConfig( callback: (config: any) => any, - withSettings = false, + settings: Record | null = null, closeSlideout = true ): Promise { const config = callback(this.config); @@ -396,10 +427,11 @@ export class Element extends Base { return; } - // Craft.ui error helpers require jQuery fields — keep them at the seam. - this.fieldsWithErrors.forEach(($field: any) => { - Craft.ui.clearErrorsFromField($field); - }); + const settingsForm = this.slideout?.settingsForm; + + if (settings && settingsForm) { + settingsForm.errors = {}; + } let data; @@ -409,33 +441,19 @@ export class Element extends Base { 'fields/apply-layout-element-settings', { data: { - uid: this.uid, - layoutConfig: this.tab.designer.config, - elementType: this.tab.designer.settings!.elementType, + ...this.settingsRequestData(), config, - settingsNamespace: this.settingsNamespace, - settings: withSettings - ? serializeFormInputs(this.slideout.$container[0]) - : null, + settings, }, } ); data = response.data; } catch (e: any) { - if (withSettings) { - const errors = e?.response?.data?.errors; - if (errors) { - Object.entries(errors).forEach(([name, fieldErrors]) => { - // Craft.ui.addErrorsToField needs a jQuery field — seam. - const $field = this.slideout.$container.find( - `[data-error-key="${name}"]` - ); - if ($field.length) { - Craft.ui.addErrorsToField($field, fieldErrors); - this.fieldsWithErrors.push($field); - } - }); - } + const errors = e?.response?.data?.errors; + + // The Vue panel renders its own errors from the rejection. + if (settings && settingsForm && errors) { + settingsForm.errors = errors; } Craft.cp.displayError(e?.response?.data?.message); @@ -498,7 +516,7 @@ export class Element extends Base { } async refresh(): Promise { - await this.applyConfig((config: any) => config, false, false); + await this.applyConfig((config: any) => config, null, false); } get index(): number { diff --git a/resources/js/modules/field-layout-designer/field-layout-designer.ts b/resources/js/modules/field-layout-designer/field-layout-designer.ts index cbce911a977..b0a8c31a15d 100644 --- a/resources/js/modules/field-layout-designer/field-layout-designer.ts +++ b/resources/js/modules/field-layout-designer/field-layout-designer.ts @@ -298,7 +298,7 @@ export class FieldLayoutDesigner extends Base { if (skipLinkAnchor) { const $skipLink = document.createElement('a'); - $skipLink.className = 'skip-link btn'; + $skipLink.className = 'skip-link'; $skipLink.textContent = Craft.t('app', 'Skip to card view designer'); $skipLink.href = `#${skipLinkAnchor}`; @@ -554,7 +554,17 @@ export class FieldLayoutDesigner extends Base { $body.className = 'fld-element-settings-body'; const $fields = document.createElement('div'); $fields.className = 'fields'; - $fields.innerHTML = data.settingsHtml; + const $form = document.createElement( + 'craft-layout-component-settings-form' + ) as HTMLElement & { + payload: unknown; + requestData: () => unknown; + }; + $form.payload = data.form; + if (settings.requestData) { + $form.requestData = settings.requestData; + } + $fields.appendChild($form); $body.appendChild($fields); const $footer = document.createElement('div'); @@ -593,11 +603,13 @@ export class FieldLayoutDesigner extends Base { ); slideout.on('open', () => { - // Hold off a sec until it's positioned... + // Hold off until it's positioned and the form has mounted... requestAnimationFrame(() => { - // Focus on the first text input + // Focus on the first editable control ( - slideout.$container[0].querySelector('.text') as HTMLElement | null + slideout.$container[0].querySelector( + 'input:not([type=hidden]):not([disabled]), textarea:not([disabled]), craft-input, craft-combobox, .text' + ) as HTMLElement | null )?.focus(); }); }); @@ -615,6 +627,8 @@ export class FieldLayoutDesigner extends Base { Craft.initUiElements(slideout.$container); + (slideout as any).settingsForm = $form; + return slideout; } } diff --git a/resources/js/modules/field-layout-designer/settings-slideout.ts b/resources/js/modules/field-layout-designer/settings-slideout.ts new file mode 100644 index 00000000000..eebb1d6e74e --- /dev/null +++ b/resources/js/modules/field-layout-designer/settings-slideout.ts @@ -0,0 +1,77 @@ +export interface OpenLayoutSettingsOptions { + title: string; + triggerElement?: HTMLElement | null; + /** Identifies the component being edited, for the refresh request. */ + requestData: () => Record; + /** Persists the settings. Rejects with the axios error on a failure. */ + apply: (values: Record) => Promise; +} + +/** + * Whether the Vue slideout stack is available on this page. + * + * `SlideoutHost` is only mounted by the Inertia CP shell, and the designer is + * also reachable from legacy-stack screens via the `fieldLayoutDesigner()` + * Twig function. The globals are registered when that shell boots, so their + * presence is the documented signal for "this is an Inertia page". + */ +export function canUseVueSlideout(): boolean { + return typeof (window as any).Craft?.openSlideout === 'function'; +} + +/** + * Opens a layout component's settings in a Vue slideout panel. + * + * Opened with `openSlideoutWith()` rather than `openSlideout()`: the form is + * built by POSTing the layout currently being edited, which is unsaved client + * state with no URL to fetch. The panel owns its own Save and Cancel, so the + * caller doesn't wire a footer or a submit handler. + * + * The Vue side is imported on demand. The designer is loaded by the legacy + * bundle too, and a static import would drag the whole Inertia shell into + * every page that renders a field layout. + * + * Returns false when the panel was not opened — the user declined to discard + * unsaved changes in a panel this one would have replaced. + */ +export async function openLayoutComponentSettings( + data: any, + options: OpenLayoutSettingsOptions +): Promise { + const [{openSlideoutWith}, {default: LayoutComponentSettings}] = + await Promise.all([ + import('@/common/slideouts'), + import('./LayoutComponentSettings.vue'), + ]); + + const panel = openSlideoutWith( + LayoutComponentSettings as any, + { + payload: data.form, + title: options.title, + requestData: options.requestData, + apply: options.apply, + }, + {opener: options.triggerElement ?? null} + ); + + if (!panel) { + return false; + } + + // Server-rendered controls in the form (condition builders, field selects) + // register their own assets. + const craft = Craft as typeof Craft & { + appendHeadHtml(html: string): Promise; + appendBodyHtml(html: string): Promise; + }; + + if (data.headHtml) { + await craft.appendHeadHtml(data.headHtml); + } + if (data.bodyHtml) { + await craft.appendBodyHtml(data.bodyHtml); + } + + return true; +} diff --git a/resources/js/modules/field-layout-designer/tab.ts b/resources/js/modules/field-layout-designer/tab.ts index c2876bf9a37..2482a505c61 100644 --- a/resources/js/modules/field-layout-designer/tab.ts +++ b/resources/js/modules/field-layout-designer/tab.ts @@ -1,5 +1,9 @@ import {Base, HUD} from '@craftcms/garnish'; import {FieldLayoutDesigner} from './field-layout-designer'; +import { + canUseVueSlideout, + openLayoutComponentSettings, +} from './settings-slideout'; import {Element as FldElement} from './element'; import { firstFocusableInSiblings, @@ -7,7 +11,7 @@ import { fldTabData, hudData, } from './support'; -import {serializeFormInputs, type ActionMenuItem} from '@craftcms/ui'; +import {type ActionMenuItem} from '@craftcms/ui'; declare const Craft: any; declare const $: any; @@ -25,7 +29,6 @@ export class Tab extends Base { $addBtn: any = null; $actionBtn: any = null; slideout: any = null; - settingsNamespace: any = null; hud: any = null; destroyed = false; @@ -171,6 +174,14 @@ export class Tab extends Base { $tab.appendChild(menu); } + private settingsRequestData(): Record { + return { + uid: this.uid, + layoutConfig: this.designer.config, + elementType: this.designer.settings!.elementType, + }; + } + async createSettings(): Promise { let data; try { @@ -179,9 +190,7 @@ export class Tab extends Base { 'fields/render-layout-component-settings', { data: { - uid: this.uid, - layoutConfig: this.designer.config, - elementType: this.designer.settings!.elementType, + ...this.settingsRequestData(), }, } ); @@ -191,9 +200,20 @@ export class Tab extends Base { throw e; } - this.settingsNamespace = data.namespace; + if (canUseVueSlideout()) { + await openLayoutComponentSettings(data, { + title: this.config?.name || Craft.t('app', 'Settings'), + triggerElement: this.$actionBtn, + requestData: () => this.settingsRequestData(), + apply: (settings) => this.applyTabSettings(settings), + }); + + return; + } + this.slideout = await FieldLayoutDesigner.createSlideout(data, { triggerElement: this.$actionBtn, + requestData: () => this.settingsRequestData(), }); // slideout.$container is a Craft jQuery object; bind on the native form. @@ -209,53 +229,70 @@ export class Tab extends Base { applySettings(): void { const $container = this.slideout.$container[0]; - const $nameInput = $container.querySelector('[name$="[name]"]'); - if (!$nameInput?.value) { - Craft.cp.displayError(Craft.t('app', 'You must specify a tab name.')); - return; - } + const settingsForm = this.slideout.settingsForm; + const settings = settingsForm?.currentValues() ?? {}; // update the UI const $submitBtn = $container.querySelector('button[type=submit]'); $submitBtn?.classList.add('loading'); - const config = Object.assign({}, this.config); - delete config.elements; - - Craft.sendActionRequest('POST', 'fields/apply-layout-tab-settings', { - data: { - uid: this.uid, - layoutConfig: this.designer.config, - elementType: this.designer.settings!.elementType, - config, - settingsNamespace: this.settingsNamespace, - settings: serializeFormInputs(this.slideout.$container[0]), - }, - }) - .then((response: any) => { - this.updateConfig((config) => - Object.assign(response.data.config, {elements: config.elements}) - ); - // Preserve the action menu across the label re-render. - const $label = this.$container.querySelector('.tabs .tab'); - const $menu = $label.querySelector(':scope > craft-action-menu'); - $menu?.remove(); - $label.innerHTML = response.data.labelHtml; - if ($menu) { - $label.appendChild($menu); - } - this.slideout.close(); - }) + this.applyTabSettings(settings) .catch((e: any) => { - Craft.cp.displayError(); - console.error(e); + Craft.cp.displayError( + e?.name === 'TabNameRequired' ? e.message : undefined + ); }) .finally(() => { $submitBtn?.classList.remove('loading'); - this.slideout.close(); + this.slideout?.close(); }); } + /** + * Persists the tab's settings and re-renders its label. + * + * Rejects on failure so the Vue settings panel can surface the errors + * against the fields they belong to. + */ + async applyTabSettings(settings: Record): Promise { + if (!settings.name) { + const message = Craft.t('app', 'You must specify a tab name.'); + + throw Object.assign(new Error(message), { + name: 'TabNameRequired', + response: {data: {errors: {name: message}}}, + }); + } + + const config = Object.assign({}, this.config); + delete config.elements; + + const response = await Craft.sendActionRequest( + 'POST', + 'fields/apply-layout-tab-settings', + { + data: { + ...this.settingsRequestData(), + config, + settings, + }, + } + ); + + this.updateConfig((config) => + Object.assign(response.data.config, {elements: config.elements}) + ); + + // Preserve the action menu across the label re-render. + const $label = this.$container.querySelector('.tabs .tab'); + const $menu = $label.querySelector(':scope > craft-action-menu'); + $menu?.remove(); + $label.innerHTML = response.data.labelHtml; + if ($menu) { + $label.appendChild($menu); + } + } + moveLeft(): void { const $prev = this.$container.previousElementSibling; if ($prev && $prev.matches('.fld-tab')) { diff --git a/resources/js/modules/forms/ActionNode.vue b/resources/js/modules/forms/ActionNode.vue new file mode 100644 index 00000000000..b8f08c5018a --- /dev/null +++ b/resources/js/modules/forms/ActionNode.vue @@ -0,0 +1,98 @@ + + + diff --git a/resources/js/modules/forms/CheckboxControl.vue b/resources/js/modules/forms/CheckboxControl.vue new file mode 100644 index 00000000000..faba32bf514 --- /dev/null +++ b/resources/js/modules/forms/CheckboxControl.vue @@ -0,0 +1,41 @@ + + + diff --git a/resources/js/modules/forms/FieldNode.vue b/resources/js/modules/forms/FieldNode.vue index ab80b8d4ba4..cd2822ef038 100644 --- a/resources/js/modules/forms/FieldNode.vue +++ b/resources/js/modules/forms/FieldNode.vue @@ -1,6 +1,7 @@ + + diff --git a/resources/js/modules/forms/layout-component-settings-form-host.test.ts b/resources/js/modules/forms/layout-component-settings-form-host.test.ts new file mode 100644 index 00000000000..7d2be8db23d --- /dev/null +++ b/resources/js/modules/forms/layout-component-settings-form-host.test.ts @@ -0,0 +1,105 @@ +import {nextTick} from 'vue'; +import {afterEach, expect, it} from 'vite-plus/test'; +import {createCpComponentRegistry} from '@/bootstrap/components'; +import {defineLayoutComponentSettingsFormHost} from './layout-component-settings-form-host'; +import ActionNode from './ActionNode.vue'; +import CheckboxControl from './CheckboxControl.vue'; +import FieldNode from './FieldNode.vue'; +import TextControl from './TextControl.vue'; +import type {FormPayload} from './types'; + +afterEach(() => document.body.replaceChildren()); + +type Host = HTMLElement & { + payload: FormPayload | null; + errors: Record; + currentValues(): Record; +}; + +function payload(): FormPayload { + return { + scope: ['settings'], + refreshable: true, + nodes: [ + { + type: 'Field', + component: 'craft:field', + props: {label: 'Label', required: false, hasActions: true}, + control: { + type: 'Text', + component: 'craft:text', + props: {}, + path: ['settings', 'label'], + mode: 'editable', + deltaGroup: ['settings', 'label'], + }, + children: [ + { + type: 'Action', + component: 'craft:action', + props: {}, + control: { + type: 'Checkbox', + component: 'craft:checkbox', + props: {label: 'Hide'}, + path: ['settings', 'labelHidden'], + mode: 'editable', + deltaGroup: ['settings', 'labelHidden'], + }, + }, + ], + }, + ], + values: {settings: {label: 'Heading', labelHidden: false}}, + errors: [], + globalErrors: [], + } as FormPayload; +} + +function mountHost(): Host { + const components = createCpComponentRegistry(); + components.register('craft:field', FieldNode); + components.register('craft:action', ActionNode); + components.register('craft:text', TextControl); + components.register('craft:checkbox', CheckboxControl); + defineLayoutComponentSettingsFormHost(components); + + const host = document.createElement( + 'craft-layout-component-settings-form' + ) as Host; + host.payload = payload(); + document.body.append(host); + + return host; +} + +it('renders action controls into the field’s actions slot', async () => { + const host = mountHost(); + await nextTick(); + + const checkbox = host.querySelector('craft-checkbox'); + expect(checkbox).not.toBeNull(); + expect(checkbox?.closest('[slot="actions"]')).not.toBeNull(); + expect(checkbox?.getAttribute('name')).toBe('settings[labelHidden]'); +}); + +it('returns settings values unwrapped from the form scope', async () => { + const host = mountHost(); + await nextTick(); + + expect(host.currentValues()).toEqual({ + label: 'Heading', + labelHidden: false, + }); +}); + +it('prefixes assigned errors with the form scope', async () => { + const host = mountHost(); + await nextTick(); + + host.errors = {handle: 'Handle is taken.', label: ['Too long.']}; + await nextTick(); + + expect(host.payload).not.toBeNull(); + expect(host.querySelector('.error-list')?.textContent).toContain('Too long.'); +}); diff --git a/resources/js/modules/forms/layout-component-settings-form-host.ts b/resources/js/modules/forms/layout-component-settings-form-host.ts new file mode 100644 index 00000000000..5ecb0faaf7c --- /dev/null +++ b/resources/js/modules/forms/layout-component-settings-form-host.ts @@ -0,0 +1,146 @@ +import type {CpComponentRegistry} from '@/bootstrap/components'; +import {createApp, defineComponent, h, ref, shallowRef, type App} from 'vue'; +import FormRenderer from './FormRenderer.vue'; +import type {FormPayload} from './types'; + +// TODO: Remove this legacy bridge once the field layout designer's settings +// slideout is rendered by the Inertia/Vue CP. + +type FormErrors = Record; + +type CraftRuntime = typeof Craft & { + appendHeadHtml(html: string): Promise; + appendBodyHtml(html: string): Promise; +}; +type FormRendererInstance = { + currentValues(): FormPayload['values']; +}; + +/** The layout component this settings form is for, as posted to the server. */ +export type LayoutComponentRequestData = { + uid: string; + elementType: string; + layoutConfig: unknown; + config?: unknown; +}; + +export function defineLayoutComponentSettingsFormHost( + components: CpComponentRegistry +): void { + if (customElements.get('craft-layout-component-settings-form')) { + return; + } + + customElements.define( + 'craft-layout-component-settings-form', + class extends HTMLElement { + readonly #payload = shallowRef(null); + readonly #errors = shallowRef([]); + readonly #renderer = ref(null); + #app: App | null = null; + #requestData: (() => LayoutComponentRequestData) | null = null; + + set payload(payload: FormPayload | null) { + this.#payload.value = payload; + this.#errors.value = payload?.errors ?? []; + } + + get payload(): FormPayload | null { + return this.#payload.value; + } + + set requestData(requestData: () => LayoutComponentRequestData) { + this.#requestData = requestData; + } + + set errors(errors: FormErrors) { + const scope = this.#payload.value?.scope ?? []; + this.#errors.value = Object.entries(errors).map(([path, messages]) => ({ + path: [...scope, ...path.split('.')], + messages: Array.isArray(messages) ? messages : [messages], + })); + } + + connectedCallback(): void { + if (this.#app) { + return; + } + + this.#app = createApp( + defineComponent({ + setup: () => { + return () => + this.#payload.value + ? h(FormRenderer, { + ref: this.#renderer, + payload: this.#payload.value, + errors: this.#errors.value, + refresh: this.#payload.value.refreshable + ? this.#refresh.bind(this) + : undefined, + }) + : null; + }, + }) + ); + this.#app.config.compilerOptions.isCustomElement = (tag) => + tag.includes('-'); + components.install(this.#app); + this.#app.mount(this); + } + + disconnectedCallback(): void { + if (this.#app) { + components.uninstall(this.#app); + this.#app.unmount(); + } + this.#app = null; + } + + /** The settings values, relative to the component (not the form scope). */ + currentValues(): Record { + const values = this.#renderer.value?.currentValues() ?? {}; + + return (values.settings ?? {}) as Record; + } + + async #refresh( + values: FormPayload['values'], + scope: string[] = this.#payload.value?.scope ?? [] + ): Promise { + if (!this.#requestData) { + throw new Error( + 'Layout component request data is required to refresh its settings.' + ); + } + + const {data} = await Craft.sendActionRequest( + 'POST', + 'fields/refresh-layout-component-settings', + { + data: { + // `values` is already relative to `scope`, unlike currentValues(). + ...this.#requestData(), + settings: values, + scope, + }, + } + ); + + if (!data.form) { + throw new Error( + 'The layout component did not return a Form payload.' + ); + } + + // Server-rendered controls (condition builders, field selects) register + // their own assets on every render. + const craft = Craft as CraftRuntime; + await craft.appendHeadHtml(data.headHtml); + await craft.appendBodyHtml(data.bodyHtml); + + return data.form; + } + } + ); +} diff --git a/resources/js/modules/forms/register.ts b/resources/js/modules/forms/register.ts index 5e6ab4c6ae0..08bc03ed859 100644 --- a/resources/js/modules/forms/register.ts +++ b/resources/js/modules/forms/register.ts @@ -1,4 +1,6 @@ import type {CpComponentRegistry} from '@/bootstrap/components'; +import ActionNode from './ActionNode.vue'; +import CheckboxControl from './CheckboxControl.vue'; import FieldNode from './FieldNode.vue'; import ChoiceControl from './ChoiceControl.vue'; import ConditionBuilderControl from './ConditionBuilderControl.vue'; @@ -18,6 +20,7 @@ import IconPickerControl from './IconPickerControl.vue'; import ElementSelectControl from './ElementSelectControl.vue'; import GroupedEntryTypeManagerControl from './GroupedEntryTypeManagerControl.vue'; import FieldLayoutDesignerControl from './FieldLayoutDesignerControl.vue'; +import FieldSelectControl from './FieldSelectControl.vue'; import MatrixControl from './MatrixControl.vue'; import ContentBlockControl from './ContentBlockControl.vue'; import DateTimeControl from './DateTimeControl.vue'; @@ -39,6 +42,7 @@ export function registerFormComponents( ): void { components.register('craft:form', FormRenderer); components.register('craft:field', FieldNode); + components.register('craft:action', ActionNode); components.register('craft:group', GroupNode); components.register('craft:tab', TabNode); components.register('craft:template-content', TemplateContentNode); @@ -55,6 +59,7 @@ export function registerFormComponents( components.register('craft:combobox', ComboboxControl); components.register('craft:textarea', TextareaControl); components.register('craft:lightswitch', LightswitchControl); + components.register('craft:checkbox', CheckboxControl); components.register('craft:choice', ChoiceControl); components.register('craft:condition-builder', ConditionBuilderControl); components.register('craft:number', TextControl); @@ -71,6 +76,7 @@ export function registerFormComponents( components.register('craft:address', AddressControl); components.register('craft:icon-picker', IconPickerControl); components.register('craft:element-select', ElementSelectControl); + components.register('craft:field-select', FieldSelectControl); components.register( 'craft:grouped-entry-type-manager', GroupedEntryTypeManagerControl diff --git a/resources/templates/_includes/forms/field.twig b/resources/templates/_includes/forms/field.twig index a88011b74d1..97d40f0c9ad 100644 --- a/resources/templates/_includes/forms/field.twig +++ b/resources/templates/_includes/forms/field.twig @@ -6,6 +6,7 @@ {%- set fieldLabel = fieldLabel ?? label ?? block('label') ?? null %} {%- set labelExtra = labelExtra ?? block('labelExtra') ?? null %} +{%- set actions = actions ?? block('actions') ?? null %} {%- set instructions = instructions ?? block('instructions') ?? null %} {%- set tip = tip ?? block('tip') ?? null %} {%- set warning = warning ?? block('warning') ?? null %} diff --git a/resources/templates/_includes/forms/fld/custom-field-settings.twig b/resources/templates/_includes/forms/fld/custom-field-settings.twig deleted file mode 100644 index cf53082de3e..00000000000 --- a/resources/templates/_includes/forms/fld/custom-field-settings.twig +++ /dev/null @@ -1,98 +0,0 @@ -{% extends '_includes/forms/fld/field-settings.twig' %} -{% import '_includes/forms' as forms %} - -{% set originalField = Fields.getFieldByUid(field.getField().uid) %} - -{% block fieldSettings %} - - {% if originalField %} - {% set fieldSelectId = "fieldselect#{random()}" %} - {{ forms.fieldSelectField({ - label: 'Field'|t('app'), - id: fieldSelectId, - name: 'fieldId', - value: originalField, - limit: 1, - warning: 'Changing this may result in data loss.'|t('app'), - }) }} - - - {% endif %} - - {{ block('labelField') }} - - {{ forms.textField({ - label: 'Handle'|t('app'), - id: 'handle', - name: 'handle', - class: 'code', - autocorrect: false, - autocapitalize: false, - maxlength: 64, - width: 'full', - value: field.handle, - placeholder: defaultHandle, - errors: field.errors.get('handle'), - required: true, - data: { - 'error-key': 'handle' - }, - }) }} - - {{ block('instructionsField') }} - {{ block('tipField') }} - {{ block('warningField') }} - -{% endblock %} diff --git a/resources/templates/_includes/forms/fld/field-settings.twig b/resources/templates/_includes/forms/fld/field-settings.twig deleted file mode 100644 index 7d29482ac5f..00000000000 --- a/resources/templates/_includes/forms/fld/field-settings.twig +++ /dev/null @@ -1,97 +0,0 @@ -{% import '_includes/forms' as forms %} - -{% set hideLabelChangeJs -%} - if (this.checked) { - $(this).closest('.field').find('.text').addClass('disabled').prop('disabled', true); - } else { - $(this).closest('.field').find('.text').removeClass('disabled').prop('disabled', false); - } -{%- endset %} - -{% block fieldSettings %} - {% block labelField %} - {% embed '_includes/forms/field' with { - id: 'label', - label: 'Label'|t('app'), - data: { - 'error-key': 'label' - }, - } %} - {% block heading %} - {{ parent() }} -
- {% include '_includes/forms/checkbox' with { - id: 'label-toggle', - name: 'labelHidden', - label: 'Hide'|t('app'), - checked: labelHidden, - inputAttributes: { - onchange: hideLabelChangeJs, - }, - } %} - {% endblock %} - {% block input %} - {% include '_includes/forms/text' with { - id: 'label', - name: 'label', - value: not labelHidden ? field.label, - placeholder: defaultLabel, - disabled: labelHidden, - } %} - {% endblock %} - {% endembed %} - {% endblock %} - - {% block instructionsField %} - {{ forms.textareaField({ - label: 'Instructions'|t('app'), - id: 'instructions', - class: 'nicetext', - name: 'instructions', - value: field.instructions, - placeholder: defaultInstructions, - data: { - 'error-key': 'instructions' - }, - }) }} - - {{ forms.selectField({ - label: 'Instructions position'|t('app'), - id: 'instructions-position', - name: 'instructionsPosition', - value: field.instructionsPosition, - options: [ - {label: 'Before the input'|t('app'), value: 'before'}, - {label: 'After the input'|t('app'), value: 'after'}, - ], - }) }} - {% endblock %} - - {% block tipField %} - {{ forms.textareaField({ - label: 'Tip'|t('app'), - id: 'tip', - class: 'nicetext', - name: 'tip', - value: field.tip, - rows: 1, - data: { - 'error-key': 'tip' - }, - }) }} - {% endblock %} - - {% block warningField %} - {{ forms.textareaField({ - label: 'Warning'|t('app'), - id: 'warning', - class: 'nicetext', - name: 'warning', - value: field.warning, - rows: 1, - data: { - 'error-key': 'warning' - }, - }) }} - {% endblock %} -{% endblock %} diff --git a/routes/actions.php b/routes/actions.php index 7eaad325534..64cb8aa3086 100644 --- a/routes/actions.php +++ b/routes/actions.php @@ -291,7 +291,9 @@ Route::post('fields/render-grouped-entry-type-manager', [FieldsController::class, 'renderGroupedEntryTypeManager']); Route::post('fields/render-condition-builder', [FieldsController::class, 'renderConditionBuilder']); Route::post('fields/normalize-condition-builder', [FieldsController::class, 'normalizeConditionBuilder']); + Route::post('fields/render-field-select', [FieldsController::class, 'renderFieldSelect']); Route::post('fields/render-layout-component-settings', [FieldsController::class, 'renderLayoutComponentSettings']); + Route::post('fields/refresh-layout-component-settings', [FieldsController::class, 'refreshLayoutComponentSettings']); Route::post('fields/apply-layout-tab-settings', [FieldsController::class, 'applyLayoutTabSettings']); Route::post('fields/apply-layout-element-settings', [FieldsController::class, 'applyLayoutElementSettings']); Route::post('fields/render-card-preview', [FieldsController::class, 'renderCardPreview']); diff --git a/src/Cp/Components/Field.php b/src/Cp/Components/Field.php index 21dfed217fe..32de9c445e4 100644 --- a/src/Cp/Components/Field.php +++ b/src/Cp/Components/Field.php @@ -73,6 +73,8 @@ class Field extends ViewComponent protected string|Htmlable|Stringable|ViewComponent|null $labelExtra = null; + protected string|Htmlable|Stringable|ViewComponent|null $actions = null; + protected function tagName(): string { return 'craft-field'; @@ -207,7 +209,10 @@ public function warning(string|Stringable|null $warning): static return $this; } - /** Extra heading content (handle-copy buttons, action menus). Strings are trusted HTML. */ + /** + * Extra heading content. Strings are trusted HTML. + */ + #[\Deprecated(message: 'in 6.0. [[actions()]] should be used instead.')] public function labelExtra(string|Htmlable|Stringable|ViewComponent|null $labelExtra): static { $this->labelExtra = $labelExtra; @@ -215,6 +220,17 @@ public function labelExtra(string|Htmlable|Stringable|ViewComponent|null $labelE return $this; } + /** + * Field-level actions (hide-label toggles, copy-value buttons, field + * settings menus). Strings are trusted HTML. + */ + public function actions(string|Htmlable|Stringable|ViewComponent|null $actions): static + { + $this->actions = $actions; + + return $this; + } + /** * Overrides the inferred width behavior. By default the field spans its * column, unless the slotted control declares a `maxlength` — which @@ -275,6 +291,7 @@ protected function renderSlots(): string $tip !== '' ? $this->renderSlot('tip', new HtmlString($this->parseNotice($tip))) : '', $warning !== '' ? $this->renderSlot('warning', new HtmlString($this->parseNotice($warning))) : '', $this->renderSlot('label-extra', $this->trustedHtml($this->labelExtra)), + $this->renderSlot('actions', $this->trustedHtml($this->actions)), $this->renderSlot('heading-prefix', $this->trustedHtml($this->headingPrefix)), $this->renderSlot('heading-suffix', $this->trustedHtml($this->headingSuffix)), $errors !== [] ? $this->renderSlot('feedback', new HtmlString($this->errorListHtml($errors))) : '', diff --git a/src/Cp/FormFields.php b/src/Cp/FormFields.php index 7722ef6b951..694471af7c2 100644 --- a/src/Cp/FormFields.php +++ b/src/Cp/FormFields.php @@ -116,10 +116,14 @@ private static function fieldFromConfig(string|Stringable|callable $input, array ); $showActionMenu = ( ! empty($config['actionMenuItems']) && - ($label || $showAttribute || isset($config['labelExtra'])) + ($label || $showAttribute || isset($config['actions']) || isset($config['labelExtra'])) ); - $labelExtra = implode('', array_filter([ + self::deprecateConfig('field', $config, [ + 'labelExtra' => 'has been deprecated. `actions` should be used instead.', + ]); + + $actions = implode('', array_filter([ $showActionMenu ? app(MenuHtml::class)->disclosureMenu($config['actionMenuItems'], [ 'hiddenLabel' => t('Actions'), @@ -135,12 +139,12 @@ private static function fieldFromConfig(string|Stringable|callable $input, array 'value' => $config['attribute'], ]) : null, - isset($config['labelExtra']) ? (string) $config['labelExtra'] : null, + isset($config['actions']) ? (string) $config['actions'] : null, ])); $errors = $errors !== null && ! is_iterable($errors) ? [$errors] : $errors; - return Field::make() + $field = Field::make() ->id($config['fieldId'] ?? "$id-field") ->label($label !== null ? (string) $label : null) ->required((bool) ($config['required'] ?? false)) @@ -157,7 +161,7 @@ private static function fieldFromConfig(string|Stringable|callable $input, array ->errors($errors !== null ? collect($errors)->map(fn ($error): string => (string) $error)->all() : []) ->headingPrefix($config['headingPrefix'] ?? null) ->headingSuffix($config['headingSuffix'] ?? null) - ->labelExtra($labelExtra !== '' ? $labelExtra : null) + ->actions($actions !== '' ? $actions : null) ->input($input) ->width($config['width'] ?? null) ->attributes(Arr::merge( @@ -178,6 +182,12 @@ private static function fieldFromConfig(string|Stringable|callable $input, array $config['fieldAttributes'] ?? [], ), )); + + if (isset($config['labelExtra'])) { + $field->labelExtra((string) $config['labelExtra']); + } + + return $field; } /** @@ -1318,6 +1328,12 @@ public static function entryTypeSelectHtml(array $config): string return self::renderTemplate('_includes/forms/entryTypeSelect', $config); } + /** @param array $config */ + public static function fieldSelectHtml(array $config): string + { + return self::renderTemplate('_includes/forms/fieldSelect', $config); + } + /** @param array $config */ public static function entryTypeSelectFieldHtml(array $config): string { diff --git a/src/FieldLayout/FieldLayoutComponent.php b/src/FieldLayout/FieldLayoutComponent.php index 178451110fe..9e9e8a72995 100644 --- a/src/FieldLayout/FieldLayoutComponent.php +++ b/src/FieldLayout/FieldLayoutComponent.php @@ -6,12 +6,17 @@ use CraftCms\Cms\Component\Component; use CraftCms\Cms\Condition\Contracts\ConditionInterface; -use CraftCms\Cms\Cp\FormFields; use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionInterface; use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\FieldLayout\Events\FieldLayoutComponentShowInFormResolving; +use CraftCms\Cms\Form\Contracts\Node; +use CraftCms\Cms\Form\Controls\ConditionBuilder; +use CraftCms\Cms\Form\Form; +use CraftCms\Cms\Form\FormContext; +use CraftCms\Cms\Form\Nodes\Field; +use CraftCms\Cms\Form\Nodes\Group; +use CraftCms\Cms\Form\Nodes\Separator; use CraftCms\Cms\Support\Facades\Conditions; -use CraftCms\Cms\Support\Html; use CraftCms\Cms\User\Conditions\UserCondition; use CraftCms\Cms\User\Elements\User; use Override; @@ -179,7 +184,11 @@ public function elementCondition(mixed $elementCondition): static */ protected function normalizeCondition(mixed $condition): ?ConditionInterface { - if ($condition === null) { + // An empty array is how a ConditionBuilder Control represents “no + // condition”, and is what it posts back when nothing has been set. + // getElementCondition() may have merged `fieldLayouts` in by then, so + // treat any classless config as “no condition” rather than failing. + if ($condition === null || (is_array($condition) && ! isset($condition['class']))) { return null; } @@ -211,72 +220,102 @@ public function hasSettings(): bool } /** - * Returns the settings HTML for the layout element. + * Returns the settings Form for the layout component. * * ::: tip - * Subclasses should override [[settingsHtml()]] instead of this method. + * Subclasses should override [[settingsNodes()]] instead of this method. * ::: */ - final public function renderSettingsHtml(): string + final public function settingsForm(FormContext $context = new FormContext): ?Form { - return implode("\n
\n", array_filter([ - $this->settingsHtml(), - $this->conditionalSettingsHtml(), - ])); + $settings = $this->settingsNodes($context); + $conditions = $this->conditionalSettingsNodes($context); + + $nodes = match (true) { + $settings === [] => $conditions, + $conditions === [] => $settings, + default => [...$settings, Separator::make('settings-conditions'), ...$conditions], + }; + + return $nodes === [] ? null : Form::make($nodes); } - protected function settingsHtml(): ?string + /** @return list */ + protected function settingsNodes(FormContext $context): array { - return null; + return []; } - protected function conditionalSettingsHtml(): ?string + /** @return list */ + protected function conditionalSettingsNodes(FormContext $context): array { if (! $this->conditional()) { - return null; + return []; } - $html = Html::beginTag('fieldset', ['class' => 'pane']). - Html::tag('legend', t('Visibility Conditions')). - Html::beginTag('div'); - - $userCondition = $this->getUserCondition() ?? self::defaultUserCondition(); - $userCondition->mainTag = 'div'; - $userCondition->id = 'user-condition'; - $userCondition->name = 'userCondition'; - $userCondition->forProjectConfig = true; + return [ + $this->conditionGroupNode( + 'visibility-conditions', + t('Visibility Conditions'), + 'userCondition', + t('Only show for users who match the following rules:'), + $this->getUserCondition(), + 'elementCondition', + 'Only show when editing {type} that match the following rules:', + $this->getElementCondition(), + ), + ]; + } - $html .= FormFields::fieldHtml($userCondition->getBuilderHtml(), [ - 'label' => t('Current User Condition'), - 'instructions' => t('Only show for users who match the following rules:'), - ]); + /** + * Builds a fieldset of user/element condition builders — the shared shape + * behind the visibility and editability condition groups. + */ + protected function conditionGroupNode( + string $groupUid, + string $groupLabel, + string $userPath, + string $userInstructions, + ?ConditionInterface $userCondition, + string $elementPath, + string $elementInstructionsPattern, + ?ConditionInterface $elementCondition, + ?ConditionInterface $defaultUserCondition = null, + ?string $defaultElementConditionClass = null, + ): Group { + $children = [ + Field::make(t('Current User Condition'), ConditionBuilder::make($userPath) + ->conditionClass(($defaultUserCondition ?? self::defaultUserCondition())::class) + ->forProjectConfig() + ->value($userCondition?->getConfig() ?? [])) + ->instructions($userInstructions), + ]; // Do we know the element type? /** @var class-string|string|null $elementType */ - $elementType = $this->elementType ?? $this->getLayout()->type; + $elementType = $this->elementType ?? $this->getLayout()?->type; if ($elementType && is_subclass_of($elementType, ElementInterface::class)) { - $elementCondition = $this->getElementCondition(); - if (! $elementCondition) { - $elementCondition = clone self::defaultElementCondition($elementType); - $elementCondition->setFieldLayouts([$this->getLayout()]); - } - $elementCondition->mainTag = 'div'; - $elementCondition->id = 'element-condition'; - $elementCondition->name = 'elementCondition'; - $elementCondition->forProjectConfig = true; - - $html .= FormFields::fieldHtml($elementCondition->getBuilderHtml(), [ - 'label' => t('{type} Condition', [ - 'type' => $elementType::displayName(), - ]), - 'instructions' => t('Only show when editing {type} that match the following rules:', [ - 'type' => $elementType::pluralLowerDisplayName(), - ]), - ]); + $conditionClass = $defaultElementConditionClass + ?? self::defaultElementCondition($elementType)::class; + + // getConfig() is null for an empty layout, which has no fields to + // offer rules for anyway. + $layoutConfig = $this->getLayout()?->getConfig(); + + $children[] = Field::make( + t('{type} Condition', ['type' => $elementType::displayName()]), + ConditionBuilder::make($elementPath) + ->conditionClass($conditionClass) + ->fieldLayouts($layoutConfig === null ? [] : [$layoutConfig]) + ->forProjectConfig() + ->value($elementCondition?->getConfig() ?? []), + )->instructions(t($elementInstructionsPattern, [ + 'type' => $elementType::pluralLowerDisplayName(), + ])); } - return $html.(Html::endTag('div').Html::endTag('fieldset')); + return Group::make($groupUid, $children)->label($groupLabel); } /** diff --git a/src/FieldLayout/FieldLayoutTab.php b/src/FieldLayout/FieldLayoutTab.php index 3068b1c7db9..ac15b89b81f 100644 --- a/src/FieldLayout/FieldLayoutTab.php +++ b/src/FieldLayout/FieldLayoutTab.php @@ -5,7 +5,6 @@ namespace CraftCms\Cms\FieldLayout; use Closure; -use CraftCms\Cms\Cp\FormFields; use CraftCms\Cms\Cp\Icons; use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\Field\Contracts\FieldInterface; @@ -20,6 +19,9 @@ use CraftCms\Cms\FieldLayout\LayoutElements\Missing; use CraftCms\Cms\FieldLayout\LayoutElements\Template; use CraftCms\Cms\FieldLayout\LayoutElements\Tip; +use CraftCms\Cms\Form\Controls\Text; +use CraftCms\Cms\Form\FormContext; +use CraftCms\Cms\Form\Nodes\Field; use CraftCms\Cms\Plugin\Plugins; use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Html; @@ -172,14 +174,12 @@ public function hasSettings(): bool return true; } - protected function settingsHtml(): ?string + #[Override] + protected function settingsNodes(FormContext $context): array { - return FormFields::textFieldHtml([ - 'label' => t('Name'), - 'name' => 'name', - 'value' => $this->name, - 'required' => true, - ]); + return [ + Field::make(t('Name'), Text::make('name')->value($this->name))->required(), + ]; } /** diff --git a/src/FieldLayout/LayoutElements/BaseField.php b/src/FieldLayout/LayoutElements/BaseField.php index 83433d59e0d..d19d0de264b 100644 --- a/src/FieldLayout/LayoutElements/BaseField.php +++ b/src/FieldLayout/LayoutElements/BaseField.php @@ -12,7 +12,13 @@ use CraftCms\Cms\FieldLayout\FieldLayoutElementContext; use CraftCms\Cms\Form\Contracts\Control; use CraftCms\Cms\Form\Contracts\Node; +use CraftCms\Cms\Form\Controls\Checkbox; +use CraftCms\Cms\Form\Controls\Choice; +use CraftCms\Cms\Form\Controls\Text; +use CraftCms\Cms\Form\Controls\Textarea; use CraftCms\Cms\Form\Enums\ControlMode; +use CraftCms\Cms\Form\FormContext; +use CraftCms\Cms\Form\Nodes\Action; use CraftCms\Cms\Form\Nodes\Field; use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Facades\HtmlStack; @@ -25,7 +31,6 @@ use Override; use function CraftCms\Cms\t; -use function CraftCms\Cms\template; abstract class BaseField extends FieldLayoutElement { @@ -346,14 +351,56 @@ public function hasSettings(): bool return true; } - protected function settingsHtml(): ?string + #[Override] + protected function settingsNodes(FormContext $context): array { - return template('_includes/forms/fld/field-settings', [ - 'field' => $this, - 'defaultLabel' => $this->defaultLabel(), - 'defaultInstructions' => $this->defaultInstructions(), - 'labelHidden' => ! $this->showLabel(), - ]); + return [ + $this->labelSettingsNode($context), + ...$this->instructionsSettingsNodes($context), + ...$this->noticeSettingsNodes($context), + ]; + } + + /** + * The Label field, with the “Hide” toggle in its actions slot. Hiding the + * label disables the text input, mirroring the value the layout stores. + */ + protected function labelSettingsNode(FormContext $context): Field + { + $labelHidden = ! $this->showLabel(); + + return Field::make(t('Label'), Text::make('label') + ->value($labelHidden ? null : $this->label) + ->placeholder($this->defaultLabel()) + ->mode($labelHidden ? ControlMode::Disabled : ControlMode::Editable)) + ->actions(Action::make( + Checkbox::make('labelHidden')->label(t('Hide'))->value($labelHidden), + )); + } + + /** @return list */ + protected function instructionsSettingsNodes(FormContext $context): array + { + return [ + Field::make(t('Instructions'), Textarea::make('instructions') + ->value($this->instructions) + ->placeholder($this->defaultInstructions())), + Field::make(t('Instructions position'), Choice::make('instructionsPosition') + ->options([ + ['label' => t('Before the input'), 'value' => 'before'], + ['label' => t('After the input'), 'value' => 'after'], + ]) + ->value($this->instructionsPosition)), + ]; + } + + /** @return list */ + protected function noticeSettingsNodes(FormContext $context): array + { + return [ + Field::make(t('Tip'), Textarea::make('tip')->rows(1)->value($this->tip)), + Field::make(t('Warning'), Textarea::make('warning')->rows(1)->value($this->warning)), + ]; } #[Override] diff --git a/src/FieldLayout/LayoutElements/CustomField.php b/src/FieldLayout/LayoutElements/CustomField.php index 6d32b8689b3..6eac3771270 100644 --- a/src/FieldLayout/LayoutElements/CustomField.php +++ b/src/FieldLayout/LayoutElements/CustomField.php @@ -7,7 +7,6 @@ use CraftCms\Cms\Component\Contracts\Actionable; use CraftCms\Cms\Component\Contracts\Iconic; use CraftCms\Cms\Cp\FieldLayoutDesigner\CardDesigner; -use CraftCms\Cms\Cp\FormFields; use CraftCms\Cms\Element\Conditions\Contracts\ElementConditionInterface; use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\Field\ContentBlock; @@ -20,12 +19,16 @@ use CraftCms\Cms\Field\MissingField; use CraftCms\Cms\FieldLayout\FieldLayoutElementContext; use CraftCms\Cms\Form\Contracts\Control; +use CraftCms\Cms\Form\Controls\FieldSelect; use CraftCms\Cms\Form\Controls\Missing as MissingControl; +use CraftCms\Cms\Form\Controls\Text; use CraftCms\Cms\Form\Enums\ControlMode; +use CraftCms\Cms\Form\FormContext; +use CraftCms\Cms\Form\Nodes\Field; +use CraftCms\Cms\Form\Nodes\Group; use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\Facades\Fields; use CraftCms\Cms\Support\Facades\I18N; -use CraftCms\Cms\Support\Html; use CraftCms\Cms\Support\Str; use CraftCms\Cms\User\Conditions\UserCondition; use CraftCms\Cms\User\Elements\User; @@ -37,7 +40,6 @@ use function CraftCms\Cms\currentUser; use function CraftCms\Cms\currentUserElement; use function CraftCms\Cms\t; -use function CraftCms\Cms\template; /** * CustomField represents a custom field that can be included in field layouts. @@ -595,18 +597,29 @@ protected function selectorAttributes(): array } #[Override] - protected function settingsHtml(): ?string + protected function settingsNodes(FormContext $context): array { // Make sure setField() has had a chance to set the default values - $this->getField(); - - return template('_includes/forms/fld/custom-field-settings', [ - 'field' => $this, - 'defaultLabel' => $this->defaultLabel(), - 'defaultHandle' => $this->_originalHandle, - 'defaultInstructions' => $this->defaultInstructions(), - 'labelHidden' => ! $this->showLabel(), - ]); + $field = $this->getField(); + $originalField = Fields::getFieldByUid($field->uid); + + return [ + Group::make('custom-field-settings', array_values(array_filter([ + $originalField === null ? null : Field::make(t('Field'), FieldSelect::make('fieldId') + ->limit(1) + ->value($originalField->id)) + ->warning(t('Changing this may result in data loss.')), + $this->labelSettingsNode($context), + Field::make(t('Handle'), Text::make('handle') + ->monospace() + ->maxLength(64) + ->value($this->handle) + ->placeholder($this->_originalHandle)) + ->required(), + ...$this->instructionsSettingsNodes($context), + ...$this->noticeSettingsNodes($context), + ]))), + ]; } /** @return array{class?: list, id?: string, data: array{base-input-name: string, error-key: string, type?: class-string}} */ @@ -749,50 +762,27 @@ protected function defaultInstructions(?ElementInterface $element = null, bool $ } #[Override] - protected function conditionalSettingsHtml(): string + protected function conditionalSettingsNodes(FormContext $context): array { - $html = (string) parent::conditionalSettingsHtml(); - - $editCondition = $this->getEditCondition() ?? self::defaultEditCondition(); - $editCondition->mainTag = 'div'; - $editCondition->id = 'edit-condition'; - $editCondition->name = 'editCondition'; - $editCondition->forProjectConfig = true; - - $editConditionsHtml = FormFields::fieldHtml($editCondition->getBuilderHtml(), [ - 'label' => t('Current User Condition'), - 'instructions' => t('Only make editable for users who match the following rules:'), - ]); - - // Do we know the element type? - /** @var class-string|string|null $elementType */ - $elementType = $this->elementType ?? $this->getLayout()->type; + $elementType = $this->elementType ?? $this->getLayout()?->type; - if ($elementType && is_subclass_of($elementType, ElementInterface::class)) { - $elementEditCondition = $this->getElementEditCondition(); - if (! $elementEditCondition) { - $elementEditCondition = clone self::defaultElementEditCondition($elementType); - $elementEditCondition->setFieldLayouts([$this->getLayout()]); - } - $elementEditCondition->mainTag = 'div'; - $elementEditCondition->id = 'element-edit-condition'; - $elementEditCondition->name = 'elementEditCondition'; - $elementEditCondition->forProjectConfig = true; - - $editConditionsHtml .= FormFields::fieldHtml($elementEditCondition->getBuilderHtml(), [ - 'label' => t('{type} Condition', [ - 'type' => $elementType::displayName(), - ]), - 'instructions' => t('Only make editable when editing {type} that match the following rules:', [ - 'type' => $elementType::pluralLowerDisplayName(), - ]), - ]); - } - - return $html.Html::beginTag('fieldset', ['class' => 'pane']). - Html::tag('legend', t('Editability Conditions')). - Html::tag('div', $editConditionsHtml). - Html::endTag('fieldset'); + return [ + ...parent::conditionalSettingsNodes($context), + $this->conditionGroupNode( + 'editability-conditions', + t('Editability Conditions'), + 'editCondition', + t('Only make editable for users who match the following rules:'), + $this->getEditCondition(), + 'elementEditCondition', + 'Only make editable when editing {type} that match the following rules:', + $this->getElementEditCondition(), + self::defaultEditCondition(), + $elementType && is_subclass_of($elementType, ElementInterface::class) + ? self::defaultElementEditCondition($elementType)::class + : null, + ), + ]; } /** diff --git a/src/FieldLayout/LayoutElements/FullNameField.php b/src/FieldLayout/LayoutElements/FullNameField.php index 06a6e380eed..0cc9b207fc4 100644 --- a/src/FieldLayout/LayoutElements/FullNameField.php +++ b/src/FieldLayout/LayoutElements/FullNameField.php @@ -9,6 +9,7 @@ use CraftCms\Cms\FieldLayout\FieldLayoutElementContext; use CraftCms\Cms\Form\Contracts\Node; use CraftCms\Cms\Form\Controls\Text; +use CraftCms\Cms\Form\FormContext; use CraftCms\Cms\Form\Nodes\Field; use CraftCms\Cms\Form\Nodes\Group; use CraftCms\Cms\Support\Arr; @@ -72,14 +73,14 @@ public function formNode(FieldLayoutElementContext $context): ?Node } #[Override] - protected function settingsHtml(): ?string + protected function settingsNodes(FormContext $context): array { if (Cms::config()->showFirstAndLastNameFields) { // can't know for sure if the element will support firstName and lastName, but probably? - return null; + return []; } - return parent::settingsHtml(); + return parent::settingsNodes($context); } protected function defaultLabel(?ElementInterface $element = null, bool $static = false): ?string diff --git a/src/FieldLayout/LayoutElements/Heading.php b/src/FieldLayout/LayoutElements/Heading.php index a51ef86a264..de7af671fdc 100644 --- a/src/FieldLayout/LayoutElements/Heading.php +++ b/src/FieldLayout/LayoutElements/Heading.php @@ -4,9 +4,11 @@ namespace CraftCms\Cms\FieldLayout\LayoutElements; -use CraftCms\Cms\Cp\FormFields; use CraftCms\Cms\FieldLayout\FieldLayoutElementContext; use CraftCms\Cms\Form\Contracts\Node; +use CraftCms\Cms\Form\Controls\Text; +use CraftCms\Cms\Form\FormContext; +use CraftCms\Cms\Form\Nodes\Field; use CraftCms\Cms\Form\Nodes\Heading as HeadingNode; use InvalidArgumentException; use Override; @@ -51,14 +53,12 @@ public function hasSettings(): bool return true; } - protected function settingsHtml(): ?string + #[Override] + protected function settingsNodes(FormContext $context): array { - return FormFields::textFieldHtml([ - 'label' => t('Heading'), - 'id' => 'heading', - 'name' => 'heading', - 'value' => $this->heading, - ]); + return [ + Field::make(t('Heading'), Text::make('heading')->value($this->heading)), + ]; } #[Override] diff --git a/src/FieldLayout/LayoutElements/Markdown.php b/src/FieldLayout/LayoutElements/Markdown.php index 1cd8276286e..397f27003bb 100644 --- a/src/FieldLayout/LayoutElements/Markdown.php +++ b/src/FieldLayout/LayoutElements/Markdown.php @@ -4,9 +4,12 @@ namespace CraftCms\Cms\FieldLayout\LayoutElements; -use CraftCms\Cms\Cp\FormFields; use CraftCms\Cms\FieldLayout\FieldLayoutElementContext; use CraftCms\Cms\Form\Contracts\Node; +use CraftCms\Cms\Form\Controls\Lightswitch; +use CraftCms\Cms\Form\Controls\Textarea; +use CraftCms\Cms\Form\FormContext; +use CraftCms\Cms\Form\Nodes\Field; use CraftCms\Cms\Form\Nodes\MarkdownContent; use CraftCms\Cms\Support\Str; use InvalidArgumentException; @@ -80,22 +83,16 @@ public function hasSettings(): bool return true; } - protected function settingsHtml(): ?string + #[Override] + protected function settingsNodes(FormContext $context): array { - return - FormFields::textareaFieldHtml([ - 'label' => t('Content'), - 'class' => ['code', 'nicetext'], - 'id' => 'content', - 'name' => 'content', - 'value' => $this->content, - ]). - FormFields::lightswitchFieldHtml([ - 'label' => t('Display content in a pane'), - 'id' => 'display-in-pane', - 'name' => 'displayInPane', - 'on' => $this->displayInPane, - ]); + return [ + Field::make(t('Content'), Textarea::make('content') + ->monospace() + ->value($this->content)), + Field::make(t('Display content in a pane'), Lightswitch::make('displayInPane') + ->value($this->displayInPane)), + ]; } #[Override] diff --git a/src/FieldLayout/LayoutElements/Template.php b/src/FieldLayout/LayoutElements/Template.php index 0024a1a0108..7273d62c1bc 100644 --- a/src/FieldLayout/LayoutElements/Template.php +++ b/src/FieldLayout/LayoutElements/Template.php @@ -4,10 +4,13 @@ namespace CraftCms\Cms\FieldLayout\LayoutElements; -use CraftCms\Cms\Cp\FormFields; +use CraftCms\Cms\Cp\SelectOptions; use CraftCms\Cms\FieldLayout\FieldLayoutElementContext; use CraftCms\Cms\Form\Contracts\Node; +use CraftCms\Cms\Form\Controls\Combobox; +use CraftCms\Cms\Form\FormContext; use CraftCms\Cms\Form\Nodes\Callout; +use CraftCms\Cms\Form\Nodes\Field; use CraftCms\Cms\Form\Nodes\TemplateContent; use CraftCms\Cms\Support\Facades\HtmlStack; use CraftCms\Cms\Support\Facades\Twig; @@ -90,18 +93,17 @@ public function hasSettings(): bool return true; } - protected function settingsHtml(): ?string + #[Override] + protected function settingsNodes(FormContext $context): array { - return FormFields::autosuggestFieldHtml([ - 'label' => t('Template'), - 'instructions' => t('The path to a template file within your `templates/` folder.'), - 'tip' => t('The template receives `element` and `static` variables. Its output is sanitized and displayed as non-interactive content; form controls, scripts, and registered assets are not supported.'), - 'class' => 'code', - 'id' => 'template', - 'name' => 'template', - 'suggestTemplates' => true, - 'value' => $this->template, - ]); + return [ + Field::make(t('Template'), Combobox::make('template') + ->options(SelectOptions::getTemplateSuggestions()) + ->showAllOnEmpty() + ->value($this->template)) + ->instructions(t('The path to a template file within your `templates/` folder.')) + ->tip(t('The template receives `element` and `static` variables. Its output is sanitized and displayed as non-interactive content; form controls, scripts, and registered assets are not supported.')), + ]; } #[Override] diff --git a/src/FieldLayout/LayoutElements/Tip.php b/src/FieldLayout/LayoutElements/Tip.php index 958f74c73d9..caa7d488857 100644 --- a/src/FieldLayout/LayoutElements/Tip.php +++ b/src/FieldLayout/LayoutElements/Tip.php @@ -4,10 +4,13 @@ namespace CraftCms\Cms\FieldLayout\LayoutElements; -use CraftCms\Cms\Cp\FormFields; use CraftCms\Cms\FieldLayout\FieldLayoutElementContext; use CraftCms\Cms\Form\Contracts\Node; +use CraftCms\Cms\Form\Controls\Lightswitch; +use CraftCms\Cms\Form\Controls\Textarea; +use CraftCms\Cms\Form\FormContext; use CraftCms\Cms\Form\Nodes\Callout; +use CraftCms\Cms\Form\Nodes\Field; use InvalidArgumentException; use Override; @@ -79,24 +82,17 @@ public function hasSettings(): bool return true; } - protected function settingsHtml(): ?string + #[Override] + protected function settingsNodes(FormContext $context): array { - return - FormFields::textareaFieldHtml([ - 'label' => $this->_isTip() ? t('Tip') : t('Warning'), - 'instructions' => t('Can contain Markdown formatting.'), - 'class' => ['nicetext'], - 'id' => 'tip', - 'name' => 'tip', - 'value' => $this->tip, - ]). - FormFields::lightswitchFieldHtml([ - 'label' => t('Can be dismissed?'), - 'instructions' => t('Whether this can be dismissed by a user and not shown again.'), - 'id' => 'dismissible', - 'name' => 'dismissible', - 'on' => $this->dismissible, - ]); + return [ + Field::make($this->_isTip() ? t('Tip') : t('Warning'), Textarea::make('tip') + ->value($this->tip)) + ->instructions(t('Can contain Markdown formatting.')), + Field::make(t('Can be dismissed?'), Lightswitch::make('dismissible') + ->value($this->dismissible)) + ->instructions(t('Whether this can be dismissed by a user and not shown again.')), + ]; } #[Override] diff --git a/src/Form/Controls/Checkbox.php b/src/Form/Controls/Checkbox.php new file mode 100644 index 00000000000..c27982c0920 --- /dev/null +++ b/src/Form/Controls/Checkbox.php @@ -0,0 +1,68 @@ +props; + + return CheckboxComponent::make() + ->id($attributes['id']) + ->name($attributes['name']) + ->label($props['label'] ?? null) + ->checked((bool) $value) + ->value($props['checkedValue'] ?? '1') + ->disabled($attributes['name'] === null) + ->describedBy($attributes['aria']['describedby'] ?? null) + ->inputAttributes([ + 'aria' => ['invalid' => $attributes['aria']['invalid'] ?? null], + 'required' => $attributes['required'], + ]) + ->toHtml(); + } + + public function component(): string + { + return 'craft:checkbox'; + } + + public function label(?string $label): static + { + $this->label = $label; + + return $this; + } + + public function checkedValue(string|int|float $checkedValue): static + { + $this->checkedValue = (string) $checkedValue; + + return $this; + } + + #[\Override] + public function props(mixed $value = null): array + { + return Arr::whereNotNull([ + 'label' => $this->label, + 'checkedValue' => $this->checkedValue !== '1' ? $this->checkedValue : null, + ]); + } +} diff --git a/src/Form/Controls/ConditionBuilder.php b/src/Form/Controls/ConditionBuilder.php index d4c5ab92c8f..fc2259bab17 100644 --- a/src/Form/Controls/ConditionBuilder.php +++ b/src/Form/Controls/ConditionBuilder.php @@ -23,6 +23,9 @@ class ConditionBuilder extends Control private bool $forProjectConfig = false; + /** @var list> */ + private array $fieldLayouts = []; + public static function renderHtml(ControlPayload $control, mixed $value, array $attributes, FormHtmlRenderer $renderer): string { return self::builderHtml( @@ -32,6 +35,7 @@ public static function renderHtml(ControlPayload $control, mixed $value, array $ (bool) $control->props['forProjectConfig'], $attributes['name'], $attributes['name'] === null, + $control->props['fieldLayouts'] ?? [], ); } @@ -39,6 +43,7 @@ public static function renderHtml(ControlPayload $control, mixed $value, array $ * @param array $value * @param class-string $conditionClass * @param list $queryParams + * @param list> $fieldLayouts */ public static function builderHtml( array $value, @@ -47,8 +52,18 @@ public static function builderHtml( bool $forProjectConfig, ?string $name, bool $disabled, + array $fieldLayouts = [], ): string { - $condition = Conditions::createCondition([...$value, 'class' => $conditionClass]); + $config = [...$value, 'class' => $conditionClass]; + + // Only seed the layouts when the condition doesn’t carry its own — + // a saved condition’s config already includes them, and normalized + // form input never does. + if ($fieldLayouts !== [] && ! isset($config['fieldLayouts'])) { + $config['fieldLayouts'] = $fieldLayouts; + } + + $condition = Conditions::createCondition($config); if (! $condition instanceof BaseCondition) { throw new InvalidArgumentException("Condition [{$conditionClass}] must extend ".BaseCondition::class.'.'); } @@ -97,6 +112,18 @@ public function forProjectConfig(bool $forProjectConfig = true): static return $this; } + /** + * Field layout configs the condition can offer field-based rules for. + * + * @param list> $fieldLayouts + */ + public function fieldLayouts(array $fieldLayouts): static + { + $this->fieldLayouts = $fieldLayouts; + + return $this; + } + #[\Override] public function props(mixed $value = null): array { @@ -108,13 +135,7 @@ public function props(mixed $value = null): array 'conditionClass' => $this->conditionClass, 'queryParams' => $this->queryParams, 'forProjectConfig' => $this->forProjectConfig, + 'fieldLayouts' => $this->fieldLayouts, ]; } - - private static function leafName(string $name): string - { - preg_match('/(?:^|\[)([^\[\]]+)]?$/', $name, $matches); - - return $matches[1] ?? $name; - } } diff --git a/src/Form/Controls/Control.php b/src/Form/Controls/Control.php index 8bf7173f471..e4061ba711d 100644 --- a/src/Form/Controls/Control.php +++ b/src/Form/Controls/Control.php @@ -142,4 +142,12 @@ protected static function parentInputName(string $name): ?string return $position === false ? null : substr($name, 0, $position); } + + /** Returns the trailing segment of a bracketed input name. */ + protected static function leafName(string $name): string + { + preg_match('/(?:^|\[)([^\[\]]+)]?$/', $name, $matches); + + return $matches[1] ?? $name; + } } diff --git a/src/Form/Controls/FieldSelect.php b/src/Form/Controls/FieldSelect.php new file mode 100644 index 00000000000..9e5633177c7 --- /dev/null +++ b/src/Form/Controls/FieldSelect.php @@ -0,0 +1,85 @@ +props['limit'] ?? null, + (bool) ($control->props['create'] ?? false), + $attributes['name'], + $attributes['name'] === null, + ); + } + + public static function selectHtml( + ?int $value, + ?int $limit, + bool $create, + ?string $name, + bool $disabled, + ): string { + $field = $value === null ? null : Fields::getFieldById($value); + $namespace = $name === null ? null : self::parentInputName($name); + + return InputNamespace::namespaceInputs(fn (): string => FormFields::fieldSelectHtml([ + 'id' => 'field-select', + 'name' => $name === null ? 'fieldId' : self::leafName($name), + 'value' => $field, + 'limit' => $limit, + 'create' => $create, + 'disabled' => $disabled, + ]), $namespace); + } + + public function component(): string + { + return 'craft:field-select'; + } + + public function limit(?int $limit): static + { + $this->limit = $limit; + + return $this; + } + + /** Whether the picker offers a "create a new field" action. */ + public function create(bool $create = true): static + { + $this->create = $create; + + return $this; + } + + #[\Override] + public function props(mixed $value = null): array + { + return Arr::whereNotNull([ + 'limit' => $this->limit, + 'create' => $this->create ?: null, + ]); + } +} diff --git a/src/Form/FormControlTypes.php b/src/Form/FormControlTypes.php index 42bbabc5f2f..b6aec8ce0a8 100644 --- a/src/Form/FormControlTypes.php +++ b/src/Form/FormControlTypes.php @@ -7,6 +7,7 @@ use CraftCms\Cms\Component\TypeRegistry; use CraftCms\Cms\Form\Contracts\Control; use CraftCms\Cms\Form\Controls\Address; +use CraftCms\Cms\Form\Controls\Checkbox; use CraftCms\Cms\Form\Controls\Choice; use CraftCms\Cms\Form\Controls\Color; use CraftCms\Cms\Form\Controls\Combobox; @@ -16,6 +17,7 @@ use CraftCms\Cms\Form\Controls\DateTime; use CraftCms\Cms\Form\Controls\ElementSelect; use CraftCms\Cms\Form\Controls\FieldLayoutDesigner; +use CraftCms\Cms\Form\Controls\FieldSelect; use CraftCms\Cms\Form\Controls\GroupedEntryTypeManager; use CraftCms\Cms\Form\Controls\Handle; use CraftCms\Cms\Form\Controls\Hidden; @@ -47,6 +49,7 @@ class FormControlTypes extends TypeRegistry protected const array DEFAULT_TYPES = [ Address::class, + Checkbox::class, Choice::class, ConditionBuilder::class, Color::class, @@ -56,6 +59,7 @@ class FormControlTypes extends TypeRegistry DateTime::class, ElementSelect::class, FieldLayoutDesigner::class, + FieldSelect::class, GroupedEntryTypeManager::class, Handle::class, Hidden::class, diff --git a/src/Form/FormNodeTypes.php b/src/Form/FormNodeTypes.php index 57c3aafbb98..88df9ef588b 100644 --- a/src/Form/FormNodeTypes.php +++ b/src/Form/FormNodeTypes.php @@ -6,6 +6,7 @@ use CraftCms\Cms\Component\TypeRegistry; use CraftCms\Cms\Form\Contracts\Node; +use CraftCms\Cms\Form\Nodes\Action; use CraftCms\Cms\Form\Nodes\Callout; use CraftCms\Cms\Form\Nodes\Field; use CraftCms\Cms\Form\Nodes\Group; @@ -30,6 +31,7 @@ class FormNodeTypes extends TypeRegistry protected const string CONTRACT = Node::class; protected const array DEFAULT_TYPES = [ + Action::class, Callout::class, Field::class, Group::class, diff --git a/src/Form/Nodes/Action.php b/src/Form/Nodes/Action.php new file mode 100644 index 00000000000..4c755c75e96 --- /dev/null +++ b/src/Form/Nodes/Action.php @@ -0,0 +1,74 @@ +actions(Action::make( + * Checkbox::make('labelHidden')->label(t('Hide')), + * )); + */ +class Action implements Node +{ + private function __construct(private readonly Control $control) {} + + public static function make(Control $control): self + { + return new self($control); + } + + public function mode(ControlMode|string $mode): static + { + $this->control->mode($mode); + + return $this; + } + + public static function renderHtml(NodePayload $node, FormPayload $payload, FormHtmlRenderer $renderer): string + { + return $renderer->renderControl( + $node->control, + $payload->values, + $renderer->id($node->control->path), + $renderer->errorsFor($payload->errors, $node->control->path) !== [], + false, + ); + } + + public function component(): string + { + return 'craft:action'; + } + + public function uid(): ?string + { + return null; + } + + public function props(): array + { + return []; + } + + public function getControl(): ?Control + { + return $this->control; + } + + public function children(): array + { + return []; + } +} diff --git a/src/Form/Nodes/Field.php b/src/Form/Nodes/Field.php index 7ee96f118a9..7847b7ec44d 100644 --- a/src/Form/Nodes/Field.php +++ b/src/Form/Nodes/Field.php @@ -14,6 +14,7 @@ use CraftCms\Cms\Support\Facades\Markdown; use CraftCms\Cms\Support\Html; use Illuminate\Support\Arr; +use Illuminate\Support\HtmlString; use InvalidArgumentException; class Field implements Node @@ -36,6 +37,9 @@ class Field implements Node private ?Control $control = null; + /** @var list */ + private array $actions = []; + public static function renderHtml(NodePayload $node, FormPayload $payload, FormHtmlRenderer $renderer): string { $control = $node->control; @@ -56,7 +60,10 @@ public static function renderHtml(NodePayload $node, FormPayload $payload, FormH (bool) ($node->props['required'] ?? false), ); + $actions = $node->children ?? []; + return FieldComponent::make() + ->actions($actions === [] ? null : new HtmlString($renderer->renderNodes($actions, $payload))) ->label($label) ->instructions($instructions) ->instructionsPosition((string) ($node->props['instructionsPosition'] ?? 'before')) @@ -147,6 +154,17 @@ public function control(Control $control): static return $this; } + /** + * Nodes rendered into the field heading's `actions` slot — hide-label + * toggles, copy-value buttons, field settings menus. + */ + public function actions(Node ...$actions): static + { + $this->actions = array_values($actions); + + return $this; + } + public function getControl(): ?Control { return $this->control; @@ -176,13 +194,14 @@ public function props(): array 'warningHtml' => $this->noticeHtml($this->warning), 'layoutUid' => $this->layoutUid, 'width' => $this->width, + 'hasActions' => $this->actions === [] ? null : true, ]), ]; } public function children(): array { - return []; + return $this->actions; } private function noticeHtml(?string $notice): ?string diff --git a/src/Http/Controllers/FieldsController.php b/src/Http/Controllers/FieldsController.php index 65947f2eb5c..defabdc92be 100644 --- a/src/Http/Controllers/FieldsController.php +++ b/src/Http/Controllers/FieldsController.php @@ -23,16 +23,18 @@ use CraftCms\Cms\FieldLayout\LayoutElements\CustomField; use CraftCms\Cms\Form\Controls\ConditionBuilder as ConditionBuilderControl; use CraftCms\Cms\Form\Controls\FieldLayoutDesigner as FieldLayoutDesignerControl; +use CraftCms\Cms\Form\Controls\FieldSelect as FieldSelectControl; use CraftCms\Cms\Form\Controls\GroupedEntryTypeManager as GroupedEntryTypeManagerControl; +use CraftCms\Cms\Form\FormContext; +use CraftCms\Cms\Form\FormPayload; +use CraftCms\Cms\Form\FormResolver; 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; use CraftCms\Cms\Support\Html; -use CraftCms\Cms\Support\Str; use CraftCms\Cms\Support\Url; use CraftCms\Cms\View\HtmlStack; use Illuminate\Http\JsonResponse; @@ -269,6 +271,31 @@ public function renderGroupedEntryTypeManager(Request $request): JsonResponse ]); } + public function renderFieldSelect(Request $request): JsonResponse + { + $data = $request->validate([ + 'value' => ['nullable', 'integer'], + 'limit' => ['nullable', 'integer'], + 'create' => ['required', 'boolean'], + 'name' => ['required', 'string'], + 'disabled' => ['required', 'boolean'], + ]); + + $html = FieldSelectControl::selectHtml( + $data['value'] ?? null, + $data['limit'] ?? null, + $data['create'], + $data['name'], + $data['disabled'], + ); + + return new JsonResponse([ + 'html' => $html, + 'headHtml' => $this->HtmlStack->headHtml(), + 'bodyHtml' => $this->HtmlStack->bodyHtml(), + ]); + } + public function renderConditionBuilder(Request $request): JsonResponse { $data = $request->validate([ @@ -283,6 +310,7 @@ public function renderConditionBuilder(Request $request): JsonResponse 'forProjectConfig' => ['required', 'boolean'], 'name' => ['required', 'string'], 'disabled' => ['required', 'boolean'], + 'fieldLayouts' => ['nullable', 'array'], ]); $html = ConditionBuilderControl::builderHtml( @@ -292,6 +320,7 @@ public function renderConditionBuilder(Request $request): JsonResponse $data['forProjectConfig'], $data['name'], $data['disabled'], + $data['fieldLayouts'] ?? [], ); return new JsonResponse([ @@ -465,18 +494,47 @@ public function destroy(Request $request, int $fieldId): Response public function renderLayoutComponentSettings(Request $request): JsonResponse { - $element = $this->fieldLayoutComponent($request); - $namespace = Str::random(10); - $html = InputNamespace::namespaceInputs(fn () => $element->renderSettingsHtml(), $namespace); + $component = $this->fieldLayoutComponent($request); return new JsonResponse([ - 'settingsHtml' => $html, - 'namespace' => $namespace, + 'form' => $this->layoutComponentSettingsPayload($component), 'headHtml' => $this->HtmlStack->headHtml(), 'bodyHtml' => $this->HtmlStack->bodyHtml(), ]); } + public function refreshLayoutComponentSettings(Request $request): JsonResponse + { + $request->validate([ + 'scope' => ['present', 'array'], + 'scope.*' => ['string'], + ]); + + $component = $this->fieldLayoutComponent($request, $settings); + $payload = $this->layoutComponentSettingsPayload($component, $settings ?? []); + $scope = $request->array('scope'); + + return new JsonResponse([ + 'form' => $scope === [] ? $payload : $payload->forScope($scope), + 'headHtml' => $this->HtmlStack->headHtml(), + 'bodyHtml' => $this->HtmlStack->bodyHtml(), + ]); + } + + /** @param array $values */ + private function layoutComponentSettingsPayload(FieldLayoutComponent $component, array $values = []): ?FormPayload + { + $context = new FormContext( + namespace: 'settings', + values: $values === [] ? [] : ['settings' => $values], + refreshable: true, + ); + + $form = $component->settingsForm($context); + + return $form === null ? null : app(FormResolver::class)->resolve($form, $context); + } + public function applyLayoutTabSettings(Request $request): Response { /** @var FieldLayoutTab $tab */ @@ -550,8 +608,7 @@ private function fieldLayoutComponent(Request $request, ?array &$settings = null 'elementType' => ['required', 'string', new ElementTypeRule], 'layoutConfig' => ['required', 'array'], 'config' => ['nullable', 'array'], - 'settings' => ['nullable', 'string'], - 'settingsNamespace' => ['nullable', 'string'], + 'settings' => ['nullable', 'array'], ]); $uid = $request->input('uid'); @@ -564,12 +621,9 @@ private function fieldLayoutComponent(Request $request, ?array &$settings = null $componentConfig = $request->input('config', []); $componentConfig['elementType'] = $elementType; - $settingsStr = $request->input('settings'); + $settings = $request->input('settings'); - if ($settingsStr !== null) { - parse_str((string) $settingsStr, $postedSettings); - $settingsNamespace = $request->input('settingsNamespace'); - $settings = Arr::get($postedSettings, $settingsNamespace, []); + if (is_array($settings) && $settings !== []) { $componentConfig = array_merge($componentConfig, $settings); } diff --git a/tests/Feature/FieldLayout/LayoutComponentSettingsFormTest.php b/tests/Feature/FieldLayout/LayoutComponentSettingsFormTest.php new file mode 100644 index 00000000000..22353ef387d --- /dev/null +++ b/tests/Feature/FieldLayout/LayoutComponentSettingsFormTest.php @@ -0,0 +1,109 @@ +type = Entry::class; + $component->setLayout($layout); + $component->elementType = Entry::class; + + return $component; +} + +it('resolves, encodes and renders settings for every layout component', function (FieldLayoutComponent $component) { + $context = settingsContext(); + $form = $component->settingsForm($context); + + expect($form)->not->toBeNull(); + + $payload = app(FormResolver::class)->resolve($form, $context); + + expect($payload->scope)->toBe(['settings']) + ->and(Json::encode($payload))->toBeString() + ->and(app(FormHtmlRenderer::class)->render($payload))->toBeString()->not->toBe(''); +})->with([ + 'tab' => fn () => attachedTo(new FieldLayoutTab(['name' => 'Content', 'uid' => 'tab-uid'])), + 'heading' => fn () => attachedTo(new Heading(['heading' => 'Hi', 'uid' => 'heading-uid'])), + 'markdown' => fn () => attachedTo(new Markdown(['content' => '# Hi', 'uid' => 'md-uid'])), + 'tip' => fn () => attachedTo(new Tip(['tip' => 'Careful.', 'uid' => 'tip-uid'])), + 'template' => fn () => attachedTo(new Template(['template' => '_foo', 'uid' => 'tpl-uid'])), + 'horizontal rule' => fn () => attachedTo(new HorizontalRule(['uid' => 'hr-uid'])), + 'line break' => fn () => attachedTo(new LineBreak(['uid' => 'br-uid'])), +]); + +it('separates settings from conditions, and omits the separator when there are no settings', function () { + $withSettings = attachedTo(new Heading(['heading' => 'Hi', 'uid' => 'heading-uid'])); + $conditionsOnly = attachedTo(new HorizontalRule(['uid' => 'hr-uid'])); + + $withSettingsNodes = app(FormResolver::class) + ->resolve($withSettings->settingsForm(settingsContext()), settingsContext()) + ->nodes; + $conditionsOnlyNodes = app(FormResolver::class) + ->resolve($conditionsOnly->settingsForm(settingsContext()), settingsContext()) + ->nodes; + + $separators = fn (array $nodes) => array_values(array_filter( + $nodes, + fn ($node): bool => $node->component === 'craft:separator', + )); + + expect($separators($withSettingsNodes))->toHaveCount(1) + ->and($separators($conditionsOnlyNodes))->toHaveCount(0) + ->and($conditionsOnlyNodes[0]->uid)->toBe('visibility-conditions'); +}); + +it('treats a classless condition config as no condition', function () { + // ConditionBuilder Controls post `[]` for an empty condition, and + // getElementCondition() merges `fieldLayouts` into it before normalizing. + $component = attachedTo(new Heading(['heading' => 'Hi', 'uid' => 'heading-uid'])); + $component->setUserCondition([]); + $component->setElementCondition([]); + + expect($component->getUserCondition())->toBeNull() + ->and($component->getElementCondition())->toBeNull(); + + $payload = app(FormResolver::class)->resolve( + $component->settingsForm(settingsContext()), + settingsContext(), + ); + + expect($payload->nodes)->not->toBeEmpty(); +}); + +it('builds visibility condition controls at the expected paths', function () { + $component = attachedTo(new Heading(['heading' => 'Hi', 'uid' => 'heading-uid'])); + $payload = app(FormResolver::class)->resolve( + $component->settingsForm(settingsContext()), + settingsContext(), + ); + + $group = collect($payload->nodes)->firstWhere('uid', 'visibility-conditions'); + $paths = collect($group->children)->map(fn ($child) => $child->control->path)->all(); + + expect($paths)->toBe([ + ['settings', 'userCondition'], + ['settings', 'elementCondition'], + ]); +}); diff --git a/tests/Unit/Form/ActionNodeTest.php b/tests/Unit/Form/ActionNodeTest.php new file mode 100644 index 00000000000..b009b01dd2b --- /dev/null +++ b/tests/Unit/Form/ActionNodeTest.php @@ -0,0 +1,67 @@ +actions(Action::make(Checkbox::make('labelHidden')->label('Hide'))), + ]); +} + +it('resolves action children with their own control paths', function () { + $payload = app(FormResolver::class)->resolve(actionsForm(), new FormContext); + $field = $payload->nodes[0]; + + expect($field->props['hasActions'])->toBeTrue() + ->and($field->control->path)->toBe(['label']) + ->and($field->children)->toHaveCount(1) + ->and($field->children[0]->component)->toBe('craft:action') + ->and($field->children[0]->control->path)->toBe(['labelHidden']) + ->and($field->children[0]->control->component)->toBe('craft:checkbox') + ->and($field->children[0]->control->props['label'])->toBe('Hide'); +}); + +it('omits hasActions when a field has no actions', function () { + $form = Form::make([Field::make('Label', Text::make('label'))]); + $payload = app(FormResolver::class)->resolve($form, new FormContext); + + expect($payload->nodes[0]->props)->not->toHaveKey('hasActions') + ->and($payload->nodes[0]->children)->toBeNull(); +}); + +it('binds values and errors to the action control, not the field control', function () { + $payload = app(FormResolver::class)->resolve(actionsForm(), new FormContext( + values: ['label' => 'Heading', 'labelHidden' => true], + errors: ['labelHidden' => 'Nope.'], + )); + $field = $payload->nodes[0]; + + expect($payload->values['labelHidden'])->toBeTrue() + ->and($payload->errors)->toHaveCount(1) + ->and($payload->errors[0]['path'])->toBe(['labelHidden']) + ->and($payload->globalErrors)->toBe([]) + ->and($field->children[0]->control->path)->toBe(['labelHidden']); +}); + +it('renders actions into the field’s actions slot', function () { + $payload = app(FormResolver::class)->resolve(actionsForm(), new FormContext( + values: ['labelHidden' => true], + )); + $crawler = new Crawler(app(FormHtmlRenderer::class)->render($payload)); + $action = $crawler->filter('craft-field [slot="actions"]'); + + expect($action)->toHaveCount(1) + ->and($action->filter('input[type="checkbox"][name="labelHidden"]'))->toHaveCount(1); +}); diff --git a/tests/Unit/Form/ConditionBuilderFieldLayoutsTest.php b/tests/Unit/Form/ConditionBuilderFieldLayoutsTest.php new file mode 100644 index 00000000000..f627e8da411 --- /dev/null +++ b/tests/Unit/Form/ConditionBuilderFieldLayoutsTest.php @@ -0,0 +1,63 @@ + Entry::class, 'tabs' => []]; + $form = Form::make([ + Field::make('Condition', ConditionBuilder::make('elementCondition') + ->conditionClass(ElementCondition::class) + ->fieldLayouts([$layout])), + ]); + + $payload = app(FormResolver::class)->resolve($form, new FormContext); + + expect($payload->nodes[0]->control->props['fieldLayouts'])->toBe([$layout]); +}); + +it('defaults field layouts to an empty list', function () { + $form = Form::make([ + Field::make('Condition', ConditionBuilder::make('elementCondition') + ->conditionClass(ElementCondition::class)), + ]); + + $payload = app(FormResolver::class)->resolve($form, new FormContext); + + expect($payload->nodes[0]->control->props['fieldLayouts'])->toBe([]); +}); + +it('hydrates field layout configs onto the condition', function () { + $condition = Conditions::createCondition([ + 'class' => ElementCondition::class, + 'elementType' => Entry::class, + 'fieldLayouts' => [['type' => Entry::class, 'tabs' => []]], + ]); + + expect($condition)->toBeInstanceOf(ElementCondition::class) + ->and($condition->getFieldLayouts())->toHaveCount(1) + ->and($condition->getFieldLayouts()[0])->toBeInstanceOf(FieldLayout::class); +}); + +it('renders a builder for a condition seeded with field layouts', function () { + $html = ConditionBuilder::builderHtml( + [], + ElementCondition::class, + [], + true, + 'elementCondition', + false, + [['type' => Entry::class, 'tabs' => []]], + ); + + expect($html)->toContain('elementCondition'); +}); diff --git a/tests/Unit/Form/FormExtensionTypesTest.php b/tests/Unit/Form/FormExtensionTypesTest.php index 9b21e9087aa..ffec469c12d 100644 --- a/tests/Unit/Form/FormExtensionTypesTest.php +++ b/tests/Unit/Form/FormExtensionTypesTest.php @@ -5,6 +5,7 @@ use CraftCms\Cms\Form\Contracts\Control; use CraftCms\Cms\Form\Contracts\Node; use CraftCms\Cms\Form\Controls\Address; +use CraftCms\Cms\Form\Controls\Checkbox; use CraftCms\Cms\Form\Controls\Choice; use CraftCms\Cms\Form\Controls\Color; use CraftCms\Cms\Form\Controls\Combobox; @@ -14,6 +15,7 @@ use CraftCms\Cms\Form\Controls\DateTime; use CraftCms\Cms\Form\Controls\ElementSelect; use CraftCms\Cms\Form\Controls\FieldLayoutDesigner; +use CraftCms\Cms\Form\Controls\FieldSelect; use CraftCms\Cms\Form\Controls\GroupedEntryTypeManager; use CraftCms\Cms\Form\Controls\Handle; use CraftCms\Cms\Form\Controls\Hidden as HiddenControl; @@ -39,6 +41,7 @@ use CraftCms\Cms\Form\FormPayload; use CraftCms\Cms\Form\FormResolver; use CraftCms\Cms\Form\NodePayload; +use CraftCms\Cms\Form\Nodes\Action; use CraftCms\Cms\Form\Nodes\Callout; use CraftCms\Cms\Form\Nodes\Container; use CraftCms\Cms\Form\Nodes\Field; @@ -60,9 +63,10 @@ $nodeTypes = app(FormNodeTypes::class); $controlTypes = app(FormControlTypes::class); - expect($nodeTypes->types()->all())->toBe([Callout::class, Field::class, Group::class, Heading::class, HiddenField::class, LineBreak::class, MarkdownContent::class, MissingNode::class, Separator::class, Tab::class, TemplateContent::class]) + expect($nodeTypes->types()->all())->toBe([Action::class, Callout::class, Field::class, Group::class, Heading::class, HiddenField::class, LineBreak::class, MarkdownContent::class, MissingNode::class, Separator::class, Tab::class, TemplateContent::class]) ->and($controlTypes->types()->all())->toBe([ Address::class, + Checkbox::class, Choice::class, ConditionBuilder::class, Color::class, @@ -72,6 +76,7 @@ DateTime::class, ElementSelect::class, FieldLayoutDesigner::class, + FieldSelect::class, GroupedEntryTypeManager::class, Handle::class, HiddenControl::class, @@ -93,7 +98,7 @@ new TestPlugin(app())->registerFormTypes($nodeTypes, $controlTypes); - expect($nodeTypes->types()->all())->toBe([Callout::class, Field::class, Group::class, Heading::class, HiddenField::class, LineBreak::class, MarkdownContent::class, MissingNode::class, Separator::class, Tab::class, TemplateContent::class, Notice::class]) + expect($nodeTypes->types()->all())->toBe([Action::class, Callout::class, Field::class, Group::class, Heading::class, HiddenField::class, LineBreak::class, MarkdownContent::class, MissingNode::class, Separator::class, Tab::class, TemplateContent::class, Notice::class]) ->and($controlTypes->types()->last())->toBe(Slug::class) ->and(fn () => $nodeTypes->register(Slug::class))->toThrow(InvalidArgumentException::class, Node::class) ->and(fn () => $controlTypes->register(Notice::class))->toThrow(InvalidArgumentException::class, Control::class); diff --git a/yii2-adapter/src/FieldLayout/LayoutElements/Template.php b/yii2-adapter/src/FieldLayout/LayoutElements/Template.php index 8482b7548fa..ef2ee2df542 100644 --- a/yii2-adapter/src/FieldLayout/LayoutElements/Template.php +++ b/yii2-adapter/src/FieldLayout/LayoutElements/Template.php @@ -4,8 +4,11 @@ namespace CraftCms\Yii2Adapter\FieldLayout\LayoutElements; -use CraftCms\Cms\Cp\FormFields; +use CraftCms\Cms\Cp\SelectOptions; use CraftCms\Cms\Element\Contracts\ElementInterface; +use CraftCms\Cms\Form\Controls\Combobox; +use CraftCms\Cms\Form\FormContext; +use CraftCms\Cms\Form\Nodes\Field; use CraftCms\Cms\Support\Facades\Twig; use CraftCms\Cms\Support\Html; use CraftCms\Cms\Twig\Environment; @@ -81,18 +84,16 @@ public function hasSettings(): bool return true; } - protected function settingsHtml(): ?string + protected function settingsNodes(FormContext $context): array { - return FormFields::autosuggestFieldHtml([ - 'label' => t('Template'), - 'instructions' => t('The path to a template file within your `templates/` folder.'), - 'tip' => t('The template will be rendered with an `element` variable.'), - 'class' => 'code', - 'id' => 'template', - 'name' => 'template', - 'suggestTemplates' => true, - 'value' => $this->template, - ]); + return [ + Field::make(t('Template'), Combobox::make('template') + ->options(SelectOptions::getTemplateSuggestions()) + ->showAllOnEmpty() + ->value($this->template)) + ->instructions(t('The path to a template file within your `templates/` folder.')) + ->tip(t('The template will be rendered with an `element` variable.')), + ]; } public function formHtml(?ElementInterface $element = null, bool $static = false): ?string