Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm

## [Unreleased]

### Added

- **Form block layout** ([#916](https://github.com/ArtisanPack-UI/visual-editor/issues/916)). `artisanpack/form` has a `layout` attribute (`stacked`, the default, or `inline`) with a Layout toggle in the block's settings. Inline adds `data-layout="inline"` and an `is-layout-inline` class to the wrapper so the host's form island and themes can put the submit button on the same row as the fields; the canvas preview does the same. Stacked output is unchanged, and any other value is dropped. The React and Vue renderers don't render this block, so they're unchanged.

### Fixed

- **Gradient borders show on Cover** ([#912](https://github.com/ArtisanPack-UI/visual-editor/issues/912)). The ring is a `::before` that sits outside the padding box, and Cover's `overflow: clip` clipped it away, so a Cover with a gradient border showed a transparent border. The gradient-border wrapper rule now sets `overflow-clip-margin: border-box`, in the Blade emitter and the canvas emitter, which only takes effect on blocks with `overflow: clip`. Browsers without `overflow-clip-margin` (Safari) get an `@supports not` fallback: a gradient-bordered Cover stops clipping, and its background layers take the inner radius (the wrapper's radius minus the ring width), so they keep their rounded corners.
Expand Down
146 changes: 146 additions & 0 deletions resources/js/visual-editor/blocks/form/__tests__/edit.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* Tests for the `artisanpack/form` edit component's layout option (#916).
*
* Covers the inspector's Layout toggle and the canvas preview: stacked
* keeps the column layout with the button below the fields, inline puts
* the fields and the button on one wrapping row.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';

vi.mock('@wordpress/i18n', () => ({
__: (text: string) => text,
sprintf: (text: string) => text,
}));

vi.mock('@wordpress/components', () => ({
Button: ({ children, onClick }: { children?: React.ReactNode; onClick?: () => void }) => (
<button type="button" onClick={onClick}>
{children}
</button>
),
Placeholder: ({ instructions, children }: { instructions?: string; children?: React.ReactNode }) => (
<div data-testid="placeholder">
{instructions}
{children}
</div>
),
PanelBody: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
SelectControl: () => null,
Spinner: () => null,
__experimentalToggleGroupControl: ({
label,
value,
onChange,
children,
}: {
label: string;
value: string;
onChange: (value: string) => void;
children?: React.ReactNode;
}) => (
<div role="radiogroup" aria-label={label} data-value={value}>
<button type="button" onClick={() => onChange('stacked')}>
stacked
</button>
<button type="button" onClick={() => onChange('inline')}>
inline
</button>
{children}
</div>
),
__experimentalToggleGroupControlOption: () => null,
}));

vi.mock('@wordpress/block-editor', () => ({
useBlockProps: (props?: Record<string, unknown>) => ({ ...props }),
InspectorControls: ({ children }: { children?: React.ReactNode }) => (
<div data-testid="inspector">{children}</div>
),
}));

(globalThis as { React?: unknown }).React = require('react');

import FormEdit from '../edit';

const FORM_DETAIL = {
data: {
id: 3,
name: 'Newsletter',
slug: 'newsletter',
submit_button_text: 'Subscribe',
fields: [
{
id: 11,
name: 'email',
label: 'Email',
type: 'email',
placeholder: null,
help_text: null,
is_required: true,
default_value: null,
},
],
},
};

function jsonResponse(body: unknown): Response {
return { ok: true, status: 200, json: async () => body } as Response;
}

beforeEach(() => {
vi.stubGlobal(
'fetch',
vi.fn(async (url: string) =>
String(url).includes('/render')
? jsonResponse(FORM_DETAIL)
: jsonResponse({ data: [{ id: 3, name: 'Newsletter', slug: 'newsletter', is_active: true }] }),
),
);
});

afterEach(() => {
vi.unstubAllGlobals();
});

describe('FormEdit layout', () => {
it('keeps the stacked column preview by default', async () => {
render(<FormEdit attributes={{ formId: 3 }} setAttributes={() => undefined} />);

const button = await screen.findByText('Subscribe');
const row = button.parentElement as HTMLElement;

expect(row.style.flexDirection).toBe('column');
expect(row.getAttribute('data-layout')).toBeNull();
expect(button.style.alignSelf).toBe('flex-start');
expect(button.style.marginTop).toBe('0.5rem');
expect(screen.getByRole('radiogroup', { name: 'Layout' }).getAttribute('data-value')).toBe('stacked');
});

it('puts the fields and button on one wrapping row when inline', async () => {
render(<FormEdit attributes={{ formId: 3, layout: 'inline' }} setAttributes={() => undefined} />);

const button = await screen.findByText('Subscribe');
const row = button.parentElement as HTMLElement;
const field = screen.getByLabelText(/Email/).parentElement as HTMLElement;

expect(row.getAttribute('data-layout')).toBe('inline');
expect(row.style.flexDirection).toBe('row');
expect(row.style.flexWrap).toBe('wrap');
expect(field.style.flex).toBe('1 1 12rem');
expect(field.style.minWidth).toBe('min(12rem, 100%)');
expect(button.style.alignSelf).toBe('flex-end');
expect(button.style.flex).toBe('0 0 auto');
});

it('writes the chosen layout through setAttributes', async () => {
const setAttributes = vi.fn();
render(<FormEdit attributes={{ formId: 3 }} setAttributes={setAttributes} />);

await waitFor(() => expect(screen.getByText('Subscribe')).toBeTruthy());
fireEvent.click(screen.getByText('inline'));

expect(setAttributes).toHaveBeenCalledWith({ layout: 'inline' });
});
});
8 changes: 8 additions & 0 deletions resources/js/visual-editor/blocks/form/block.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@
"formId": {
"type": "number",
"default": 0
},
"layout": {
"type": "string",
"enum": [
"stacked",
"inline"
],
"default": "stacked"
}
},
"supports": {
Expand Down
87 changes: 78 additions & 9 deletions resources/js/visual-editor/blocks/form/edit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,28 @@ import {
InspectorControls,
useBlockProps,
} from '@wordpress/block-editor';
import { Button, Placeholder, PanelBody, SelectControl, Spinner } from '@wordpress/components';
import {
Button,
Placeholder,
PanelBody,
SelectControl,
Spinner,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
__experimentalToggleGroupControl as ToggleGroupControl,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
__experimentalToggleGroupControlOption as ToggleGroupControlOption,
} from '@wordpress/components';
import { __, sprintf } from '@wordpress/i18n';

import { TEXT_DOMAIN } from '../../vendor/i18n';

import FormInserterIcon from './inserter-icon';

export type FormLayout = 'stacked' | 'inline';

interface FormAttributes {
readonly formId: number;
readonly layout?: FormLayout;
}

interface FormEditProps {
Expand Down Expand Up @@ -131,7 +144,32 @@ const PREVIEW_TOKENS = {
submitFontSize: '0.9375rem',
} as const;

function FieldPreview({ field }: { readonly field: FormField }): ReactElement {
/*
* Inline layout (#916): fields share one wrapping row with the submit
* button. Each field grows from a 12rem basis (capped to the container
* on narrow canvases) and the button sits at the bottom without growing,
* so it lines up with the inputs rather than the labels.
*/
const INLINE_ROW_STYLE: React.CSSProperties = {
display: 'flex',
flexDirection: 'row',
flexWrap: 'wrap',
alignItems: 'flex-end',
gap: '0.75rem',
};

const INLINE_FIELD_STYLE: React.CSSProperties = {
flex: '1 1 12rem',
minWidth: 'min(12rem, 100%)',
};

function FieldPreview({
field,
isInline = false,
}: {
readonly field: FormField;
readonly isInline?: boolean;
}): ReactElement {
const label = field.label ?? field.name;
const inputId = `form-block-preview-${field.id}`;
const options = field.field_config?.options ?? field.options ?? [];
Expand Down Expand Up @@ -259,7 +297,7 @@ function FieldPreview({ field }: { readonly field: FormField }): ReactElement {
}

return (
<div style={{ marginBottom: '1.25rem' }}>
<div style={isInline ? INLINE_FIELD_STYLE : { marginBottom: '1.25rem' }}>
{labelEl}
{control}
{field.help_text && (
Expand All @@ -271,7 +309,13 @@ function FieldPreview({ field }: { readonly field: FormField }): ReactElement {
);
}

function FormPreview({ formId }: { readonly formId: number }): ReactElement {
function FormPreview({
formId,
layout = 'stacked',
}: {
readonly formId: number;
readonly layout?: FormLayout;
}): ReactElement {
const [detail, setDetail] = useState<FormDetailResponse['data'] | null>(null);
const [error, setError] = useState<string | null>(null);

Expand Down Expand Up @@ -348,24 +392,29 @@ function FormPreview({ formId }: { readonly formId: number }): ReactElement {
}

const fields = detail.fields ?? [];
const isInline = 'inline' === layout;

return (
<div style={{ display: 'flex', flexDirection: 'column' }}>
<div
data-layout={isInline ? 'inline' : undefined}
style={isInline ? INLINE_ROW_STYLE : { display: 'flex', flexDirection: 'column' }}
>
{0 === fields.length ? (
<p style={{ margin: 0, fontSize: PREVIEW_TOKENS.helpFontSize, color: PREVIEW_TOKENS.helpColor }}>
{__('This form has no fields yet. Add some from the Forms admin.', TEXT_DOMAIN)}
</p>
) : (
<>
{fields.map((field) => (
<FieldPreview key={field.id} field={field} />
<FieldPreview key={field.id} field={field} isInline={isInline} />
))}
<button
type="button"
disabled
style={{
alignSelf: 'flex-start',
marginTop: '0.5rem',
...(isInline
? { flex: '0 0 auto', alignSelf: 'flex-end' }
: { alignSelf: 'flex-start', marginTop: '0.5rem' }),
padding: PREVIEW_TOKENS.submitPadding,
background: PREVIEW_TOKENS.submitBackground,
color: PREVIEW_TOKENS.submitColor,
Expand All @@ -389,6 +438,7 @@ export default function FormEdit({
setAttributes,
}: FormEditProps): ReactElement {
const { formId } = attributes;
const layout: FormLayout = 'inline' === attributes.layout ? 'inline' : 'stacked';
const [forms, setForms] = useState<ApiForm[] | null>(null);
const [error, setError] = useState<string | null>(null);

Expand Down Expand Up @@ -504,6 +554,25 @@ export default function FormEdit({
__nextHasNoMarginBottom
/>
)}
<div style={{ marginTop: '16px' }}>
<ToggleGroupControl
label={__('Layout', TEXT_DOMAIN)}
value={layout}
onChange={(value: string | number | undefined) =>
setAttributes({ layout: 'inline' === value ? 'inline' : 'stacked' })
}
isBlock
__next40pxDefaultSize
__nextHasNoMarginBottom
help={__(
'Inline puts the submit button on the same row as the fields. Best for one-field forms such as a newsletter signup.',
TEXT_DOMAIN,
)}
>
<ToggleGroupControlOption value="stacked" label={__('Stacked', TEXT_DOMAIN)} />
<ToggleGroupControlOption value="inline" label={__('Inline', TEXT_DOMAIN)} />
</ToggleGroupControl>
</div>
</PanelBody>
</InspectorControls>

Expand All @@ -525,7 +594,7 @@ export default function FormEdit({
</Button>
</Placeholder>
) : formId > 0 ? (
<FormPreview formId={formId} />
<FormPreview formId={formId} layout={layout} />
) : (
<Placeholder
icon={<FormInserterIcon />}
Expand Down
32 changes: 30 additions & 2 deletions src/Blocks/Forms/FormBlock.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,20 @@
*/
class FormBlock extends DynamicBlock
{

/**
* Allowed values for the `layout` attribute.
*
* `stacked` is the block.json default and renders the original
* mount-point markup; `inline` adds a `data-layout` hook and an
* `is-layout-inline` class for the host island and themes (#916).
*
* @since 1.15.2
*
* @var list<string>
*/
private const LAYOUTS = [ 'stacked', 'inline' ];

/**
* Resolver for the form record keyed off the validated `formId`.
*
Expand Down Expand Up @@ -115,6 +129,12 @@ public function validateAttrs( array $attrs ): array
$normalized['style'] = $attrs['style'];
}

// #916 — `layout` survives only when it's one of the enum values;
// anything else is dropped so render() falls back to stacked.
if ( isset( $attrs['layout'] ) && in_array( $attrs['layout'], self::LAYOUTS, true ) ) {
$normalized['layout'] = $attrs['layout'];
}

return $normalized;
}

Expand All @@ -136,11 +156,19 @@ public function render( array $attrs ): string
return $this->placeholder( $attrs, __( 'The selected form is inactive.' ) );
}

// #916 — the inline layout adds a class (for themes) and a
// `data-layout` attribute (for the host's form island). Stacked
// adds neither, so its markup is byte-identical to pre-#916 output.
$isInline = 'inline' === ( $attrs['layout'] ?? null );
$baseClasses = $isInline ? [ 'wp-block-artisanpack-form', 'is-layout-inline' ] : [ 'wp-block-artisanpack-form' ];
$layoutAttr = $isInline ? sprintf( ' data-layout="%s"', e( 'inline' ) ) : '';

return sprintf(
'<div%s data-keystone-form="%s" data-form-id="%d"></div>',
BlockSupports::wrapperAttrs( $attrs, [ 'wp-block-artisanpack-form' ] ),
'<div%s data-keystone-form="%s" data-form-id="%d"%s></div>',
BlockSupports::wrapperAttrs( $attrs, $baseClasses ),
e( $form->slug ),
$form->id,
$layoutAttr,
);
}

Expand Down
Loading
Loading