-
Notifications
You must be signed in to change notification settings - Fork 3
feat: Add slide template picker and templates for carousel block #83
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
f9ff8de
9179f75
569d132
7f9b4d1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| /** | ||
| * 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 | ||
| */ | ||
|
|
||
| import { getSlideTemplates } 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 ), | ||
| } ) ); | ||
|
|
||
| /* ── Tests ────────────────────────────────────────────────────────────────── */ | ||
|
|
||
| describe( 'Slide Templates', () => { | ||
| 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', () => { | ||
| const { applyFilters } = require( '@wordpress/hooks' ); | ||
| getSlideTemplates(); | ||
| expect( applyFilters ).toHaveBeenCalledWith( | ||
| 'rtcamp.carouselKit.slideTemplates', | ||
| expect.any( Array ), | ||
| ); | ||
| } ); | ||
| } ); | ||
|
|
||
| describe( 'Template Shape', () => { | ||
| const templates = getSlideTemplates(); | ||
|
|
||
| it.each( templates.map( ( t ) => [ t.name, t ] ) )( | ||
| '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( typeof template.icon ).toBe( 'object' ); | ||
| 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( 'blank template produces a paragraph block', () => { | ||
| const blocks = byName( 'blank' ).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(); | ||
| } ); | ||
| } ); | ||
| } ); | ||
| } ); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| /** | ||
| * TemplatePicker — grid of slide template options shown during block setup. | ||
| * | ||
| * @package | ||
| */ | ||
|
|
||
| import { __ } from '@wordpress/i18n'; | ||
| import { Button, Icon } from '@wordpress/components'; | ||
| import type { SlideTemplate } from '../templates'; | ||
|
|
||
| interface TemplatePickerProps { | ||
| templates: SlideTemplate[]; | ||
| onSelect: ( template: SlideTemplate ) => void; | ||
| onBack: () => void; | ||
| } | ||
|
|
||
| export default function TemplatePicker( { | ||
| templates, | ||
| onSelect, | ||
| onBack, | ||
| }: TemplatePickerProps ) { | ||
| return ( | ||
| <div className="carousel-kit-template-picker"> | ||
| <div className="carousel-kit-template-picker__grid"> | ||
| { templates.map( ( template ) => ( | ||
| <button | ||
| key={ template.name } | ||
| type="button" | ||
| className="carousel-kit-template-picker__item" | ||
| onClick={ () => onSelect( template ) } | ||
| > | ||
| <div className="carousel-kit-template-picker__icon"> | ||
| <Icon icon={ template.icon } size={ 28 } /> | ||
| </div> | ||
| <div className="carousel-kit-template-picker__label"> | ||
| { template.label } | ||
| </div> | ||
| <div className="carousel-kit-template-picker__description"> | ||
| { template.description } | ||
| </div> | ||
| </button> | ||
| ) ) } | ||
| </div> | ||
| <Button | ||
| variant="link" | ||
| className="carousel-kit-template-picker__back" | ||
| onClick={ onBack } | ||
| > | ||
| { __( 'Back', 'carousel-kit' ) } | ||
| </Button> | ||
| </div> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,6 +25,10 @@ import { createBlock, type BlockConfiguration } from '@wordpress/blocks'; | |
| import type { CarouselAttributes } from './types'; | ||
| import { EditorCarouselContext } from './editor-context'; | ||
| import type { EmblaCarouselType } from 'embla-carousel'; | ||
| import { getSlideTemplates, type SlideTemplate } from './templates'; | ||
| import TemplatePicker from './components/TemplatePicker'; | ||
|
|
||
| type SetupStep = 'slide-count' | 'template'; | ||
|
|
||
| export default function Edit( { | ||
| attributes, | ||
|
|
@@ -55,6 +59,8 @@ export default function Edit( { | |
| const [ emblaApi, setEmblaApi ] = useState<EmblaCarouselType | undefined>(); | ||
| const [ canScrollPrev, setCanScrollPrev ] = useState( false ); | ||
| const [ canScrollNext, setCanScrollNext ] = useState( false ); | ||
| const [ setupStep, setSetupStep ] = useState<SetupStep>( 'slide-count' ); | ||
| const [ pendingSlideCount, setPendingSlideCount ] = useState<number>( 0 ); | ||
|
|
||
|
Comment on lines
59
to
64
|
||
| const { replaceInnerBlocks, insertBlock } = useDispatch( 'core/block-editor' ); | ||
|
|
||
|
|
@@ -155,16 +161,35 @@ export default function Edit( { | |
| ], | ||
| ); | ||
|
|
||
| const handleSetup = ( slideCount: number ) => { | ||
| const slides = Array.from( { length: slideCount }, () => | ||
| createBlock( 'carousel-kit/carousel-slide', {}, [ | ||
| createBlock( 'core/paragraph', {} ), | ||
| ] ), | ||
| ); | ||
| /** | ||
| * Handle the initial setup of the carousel block | ||
| * | ||
| * @param {number} count - The number of slides selected by the user. | ||
| */ | ||
| const handleSlideCountPicked = ( count: number ) => { | ||
| setPendingSlideCount( count ); | ||
| setSetupStep( 'template' ); | ||
| }; | ||
|
|
||
| /** | ||
| * Handle the selection of a slide template during setup. | ||
| * | ||
| * @param {SlideTemplate} template - The slide template selected by the user. | ||
| */ | ||
| const handleTemplateSelected = ( template: SlideTemplate ) => { | ||
| // Query Loop goes directly inside the viewport; regular templates get slide wrappers. | ||
| const viewportChildren = template.isQueryLoop | ||
| ? [ createBlock( 'core/query', {}, [] ) ] | ||
| : Array.from( { length: Math.max( pendingSlideCount, 1 ) }, () => | ||
| createBlock( 'carousel-kit/carousel-slide', {}, template.innerBlocks() ), | ||
| ); | ||
|
|
||
| replaceInnerBlocks( | ||
| clientId, | ||
| [ createBlock( 'carousel-kit/carousel-viewport', {}, slides ), createNavGroup() ], | ||
| [ | ||
| createBlock( 'carousel-kit/carousel-viewport', {}, viewportChildren ), | ||
| createNavGroup(), | ||
| ], | ||
| false, | ||
| ); | ||
| }; | ||
|
|
@@ -393,30 +418,45 @@ export default function Edit( { | |
| <Placeholder | ||
| icon="columns" | ||
| label={ __( 'Carousel', 'carousel-kit' ) } | ||
| instructions={ __( 'How many slides would you like to start with?', 'carousel-kit' ) } | ||
| instructions={ | ||
| setupStep === 'slide-count' | ||
| ? __( 'How many slides would you like to start with?', 'carousel-kit' ) | ||
| : __( 'Choose a slide template:', 'carousel-kit' ) | ||
| } | ||
| className="carousel-kit-setup" | ||
| > | ||
| <div className="carousel-kit-setup__options"> | ||
| { [ 1, 2, 3, 4 ].map( ( count ) => ( | ||
| { setupStep === 'slide-count' && ( | ||
| <> | ||
| <div className="carousel-kit-setup__options"> | ||
| { [ 1, 2, 3, 4 ].map( ( count ) => ( | ||
| <Button | ||
| key={ count } | ||
| variant="secondary" | ||
| className="carousel-kit-setup__option" | ||
| onClick={ () => handleSlideCountPicked( count ) } | ||
| > | ||
| { count === 1 | ||
| ? __( '1 Slide', 'carousel-kit' ) | ||
| : `${ count } ${ __( 'Slides', 'carousel-kit' ) }` } | ||
| </Button> | ||
| ) ) } | ||
| </div> | ||
| <Button | ||
| key={ count } | ||
| variant="secondary" | ||
| className="carousel-kit-setup__option" | ||
| onClick={ () => handleSetup( count ) } | ||
| variant="link" | ||
| className="carousel-kit-setup__skip" | ||
| onClick={ handleSkip } | ||
| > | ||
| { count === 1 | ||
| ? __( '1 Slide', 'carousel-kit' ) | ||
| : `${ count } ${ __( 'Slides', 'carousel-kit' ) }` } | ||
| { __( 'Skip', 'carousel-kit' ) } | ||
| </Button> | ||
| ) ) } | ||
| </div> | ||
| <Button | ||
| variant="link" | ||
| className="carousel-kit-setup__skip" | ||
| onClick={ handleSkip } | ||
| > | ||
| { __( 'Skip', 'carousel-kit' ) } | ||
| </Button> | ||
| </> | ||
| ) } | ||
| { setupStep === 'template' && ( | ||
| <TemplatePicker | ||
| templates={ getSlideTemplates() } | ||
| onSelect={ handleTemplateSelected } | ||
| onBack={ () => setSetupStep( 'slide-count' ) } | ||
| /> | ||
|
Comment on lines
+453
to
+458
|
||
| ) } | ||
| </Placeholder> | ||
| </div> | ||
| </EditorCarouselContext.Provider> | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This assertion is brittle:
IconTypecan be a string, function, or element depending on how icons are provided (especially for filtered/custom templates). Consider asserting the icon is defined (or is one of the allowed primitive types) rather than strictlytypeof === 'object'.