Skip to content
Open
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
7 changes: 4 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"@wordpress/data": "^10.10.0",
"@wordpress/dom-ready": "^4.37.0",
"@wordpress/element": "6.38.0",
"@wordpress/hooks": "4.41.0",
"@wordpress/i18n": "^6.10.0",
"@wordpress/icons": "11.5.0",
"@wordpress/interactivity": "6.37.0",
Expand Down
180 changes: 180 additions & 0 deletions src/blocks/carousel/__tests__/templates.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
/**
* Unit tests for slide template definitions and the template registry.
*
* Verifies:
* - All default templates have the required shape
* - Template inner blocks produce valid BlockInstance arrays
* - Query Loop template is flagged correctly
* - The `rtcamp.carouselKit.slideTemplates` filter hook is applied
*
* @package
*/

/// <reference types="jest" />

import { applyFilters } from '@wordpress/hooks';
import { getSlideTemplates, type SlideTemplate } from '../templates';

/* ── Mocks ────────────────────────────────────────────────────────────────── */

// Provide a minimal createBlock mock that returns a plain object.
jest.mock( '@wordpress/blocks', () => ( {
createBlock: jest.fn( ( name: string, attrs = {}, inner = [] ) => ( {
name,
attributes: attrs,
innerBlocks: inner,
clientId: `mock-${ name }-${ Math.random().toString( 36 ).slice( 2, 8 ) }`,
} ) ),
} ) );

jest.mock( '@wordpress/hooks', () => ( {
applyFilters: jest.fn( ( _hookName: string, value: unknown ) => value ),
} ) );

jest.mock( '@wordpress/i18n', () => ( {
__: jest.fn( ( str: string ) => str ),
} ) );

const mockedApplyFilters = jest.mocked( applyFilters );
let consoleWarnSpy: jest.SpiedFunction< typeof console.warn >;

/* ── Tests ────────────────────────────────────────────────────────────────── */

describe( 'Slide Templates', () => {
beforeEach( () => {
consoleWarnSpy = jest.spyOn( console, 'warn' ).mockImplementation( () => undefined );
mockedApplyFilters.mockClear();
mockedApplyFilters.mockImplementation( ( _hookName: string, value: unknown ) => value );
} );

afterEach( () => {
consoleWarnSpy.mockRestore();
} );

describe( 'getSlideTemplates()', () => {
it( 'returns an array of templates', () => {
const templates = getSlideTemplates();
expect( Array.isArray( templates ) ).toBe( true );
expect( templates.length ).toBeGreaterThanOrEqual( 5 );
} );

it( 'applies the rtcamp.carouselKit.slideTemplates filter', () => {
getSlideTemplates();
expect( mockedApplyFilters ).toHaveBeenCalledWith(
'rtcamp.carouselKit.slideTemplates',
expect.any( Array ),
);
} );

it( 'passes a fresh copy of the default templates to filters', () => {
mockedApplyFilters.mockImplementationOnce( ( _hookName: string, value: unknown ) => {
( value as SlideTemplate[] ).push( {
name: 'testimonial',
label: 'Testimonial',
description: 'Quote with author name.',
icon: 'format-quote',
innerBlocks: () => [],
} );
return value;
} );

const mutatedTemplates = getSlideTemplates();
const freshTemplates = getSlideTemplates();

expect( mutatedTemplates.map( ( template ) => template.name ) ).toContain( 'testimonial' );
expect( freshTemplates.map( ( template ) => template.name ) ).not.toContain( 'testimonial' );
} );

it( 'falls back to defaults when a filter returns a non-array value', () => {
mockedApplyFilters.mockImplementationOnce( () => 'invalid' as never );

const templates = getSlideTemplates();

expect( Array.isArray( templates ) ).toBe( true );
expect( templates.length ).toBeGreaterThanOrEqual( 5 );
expect( templates.map( ( template ) => template.name ) ).toContain( 'text' );
expect( consoleWarnSpy ).toHaveBeenCalledWith(
'rtcamp.carouselKit.slideTemplates filter returned a non-array value. Falling back to default slide templates.',
'invalid',
);
} );
} );

describe( 'Template Shape', () => {
const templates = getSlideTemplates();
const templateCases: Array<[ string, SlideTemplate ]> = templates.map( ( template ) => [
template.name,
template,
] );

it.each<[ string, SlideTemplate ]>( templateCases )(
'template "%s" has required properties',
( _name, template ) => {
expect( typeof template.name ).toBe( 'string' );
expect( template.name.length ).toBeGreaterThan( 0 );
expect( typeof template.label ).toBe( 'string' );
expect( typeof template.description ).toBe( 'string' );
expect( template.icon ).toBeDefined();
expect( template.icon ).not.toBeNull();
expect( [ 'string', 'function', 'object' ] ).toContain(
typeof template.icon,
);
expect( typeof template.innerBlocks ).toBe( 'function' );
},
);

it( 'each template has a unique name', () => {
const names = templates.map( ( t ) => t.name );
expect( new Set( names ).size ).toBe( names.length );
} );
} );

describe( 'Default Templates', () => {
const templates = getSlideTemplates();
const byName = ( name: string ) =>
templates.find( ( t ) => t.name === name )!;

it( 'text template produces a paragraph block', () => {
const blocks = byName( 'text' ).innerBlocks();
expect( blocks ).toHaveLength( 1 );
expect( blocks[ 0 ]!.name ).toBe( 'core/paragraph' );
} );

it( 'image template produces an image block', () => {
const blocks = byName( 'image' ).innerBlocks();
expect( blocks ).toHaveLength( 1 );
expect( blocks[ 0 ]!.name ).toBe( 'core/image' );
} );

it( 'hero template produces a cover with heading, paragraph, and button', () => {
const blocks = byName( 'hero' ).innerBlocks();
expect( blocks ).toHaveLength( 1 );
expect( blocks[ 0 ]!.name ).toBe( 'core/cover' );
const inner = blocks[ 0 ]!.innerBlocks;
expect( inner ).toHaveLength( 3 );
expect( inner[ 0 ]!.name ).toBe( 'core/heading' );
expect( inner[ 1 ]!.name ).toBe( 'core/paragraph' );
expect( inner[ 2 ]!.name ).toBe( 'core/buttons' );
} );

it( 'image-caption template produces an image and a paragraph', () => {
const blocks = byName( 'image-caption' ).innerBlocks();
expect( blocks ).toHaveLength( 2 );
expect( blocks[ 0 ]!.name ).toBe( 'core/image' );
expect( blocks[ 1 ]!.name ).toBe( 'core/paragraph' );
} );

it( 'query-loop template is flagged as isQueryLoop', () => {
const ql = byName( 'query-loop' );
expect( ql.isQueryLoop ).toBe( true );
} );

it( 'non-query-loop templates are not flagged as isQueryLoop', () => {
templates
.filter( ( t ) => t.name !== 'query-loop' )
.forEach( ( t ) => {
expect( t.isQueryLoop ).toBeFalsy();
} );
} );
} );
} );
61 changes: 61 additions & 0 deletions src/blocks/carousel/components/TemplatePicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* TemplatePicker — grid of slide template options shown during block setup.
*
* @package
*/

import { __ } from '@wordpress/i18n';
import { Button, Icon } from '@wordpress/components';
import { useRef, useEffect } from '@wordpress/element';
import type { SlideTemplate } from '../templates';

interface TemplatePickerProps {
templates: SlideTemplate[];
onSelect: ( template: SlideTemplate ) => void;
onBack: () => void;
}

export default function TemplatePicker( {
templates,
onSelect,
onBack,
}: TemplatePickerProps ) {
const gridRef = useRef< HTMLDivElement >( null );

useEffect( () => {
const firstButton = gridRef.current?.querySelector< HTMLButtonElement >( 'button' );
firstButton?.focus();
}, [] );

return (
<div className="rt-carousel-template-picker">
<div ref={ gridRef } className="rt-carousel-template-picker__grid">
{ templates.map( ( template ) => (
<button
key={ template.name }
type="button"
className="rt-carousel-template-picker__item"
onClick={ () => onSelect( template ) }
>
<div className="rt-carousel-template-picker__icon">
<Icon icon={ template.icon } size={ 28 } />
</div>
<div className="rt-carousel-template-picker__label">
{ template.label }
</div>
<div className="rt-carousel-template-picker__description">
{ template.description }
</div>
</button>
) ) }
</div>
<Button
variant="link"
className="rt-carousel-template-picker__back"
onClick={ onBack }
>
{ __( 'Back', 'rt-carousel' ) }
</Button>
</div>
);
}
Loading
Loading