diff --git a/packages/schema-blocks/jest.config.js b/packages/schema-blocks/jest.config.js index 51bfc8086..c176f184a 100644 --- a/packages/schema-blocks/jest.config.js +++ b/packages/schema-blocks/jest.config.js @@ -9,6 +9,5 @@ module.exports = { testURL: "http://localhost", transform: { "^.+\\.(t|j)sx?$": [ "ts-jest", "babel-jest" ], - "^.+\\.tsx?$": "ts-jest", }, }; diff --git a/packages/schema-blocks/src/core/Instruction.ts b/packages/schema-blocks/src/core/Instruction.ts index 88af349ce..4d44aba39 100644 --- a/packages/schema-blocks/src/core/Instruction.ts +++ b/packages/schema-blocks/src/core/Instruction.ts @@ -2,6 +2,7 @@ import { BlockInstance } from "@wordpress/blocks"; import logger from "../functions/logger"; import { BlockValidationResult, BlockValidation } from "./validation"; import { BlockPresence } from "./validation/BlockValidationResult"; + export type InstructionPrimitive = string | number | boolean; export type InstructionValue = InstructionPrimitive | InstructionObject | InstructionArray; export type InstructionArray = readonly InstructionValue[]; @@ -100,6 +101,7 @@ export default abstract class Instruction { if ( ! klass ) { logger.error( "Invalid instruction: ", name ); + throw new Error( "Invalid block instruction type: " + name ); } return new klass( id, options ); diff --git a/packages/schema-blocks/src/core/blocks/BlockInstruction.ts b/packages/schema-blocks/src/core/blocks/BlockInstruction.ts index db35034bb..ac7c83453 100644 --- a/packages/schema-blocks/src/core/blocks/BlockInstruction.ts +++ b/packages/schema-blocks/src/core/blocks/BlockInstruction.ts @@ -1,17 +1,9 @@ import BlockLeaf from "./BlockLeaf"; -import { RenderEditProps, RenderSaveProps } from "./BlockDefinition"; import { ReactElement } from "@wordpress/element"; +import { RenderEditProps, RenderSaveProps } from "./BlockDefinition"; import { BlockConfiguration, BlockInstance } from "@wordpress/blocks"; -import { BlockValidation, BlockValidationResult } from "../validation"; -import Instruction, { InstructionOptions } from "../Instruction"; -import { attributeExists, attributeNotEmpty } from "../../functions/validators"; -import { BlockPresence } from "../validation/BlockValidationResult"; -import { maxBy } from "lodash"; - -export type BlockInstructionClass = { - new( id: number, options: InstructionOptions ): BlockInstruction; - options: InstructionOptions; -}; +import { BlockValidation, BlockValidationResult, BlockPresence } from "../validation"; +import Instruction from "../Instruction"; /** * BlockInstruction class. @@ -64,10 +56,16 @@ export default abstract class BlockInstruction extends Instruction { /** * Returns the configuration of this instruction. * - * @returns {Partial} The configuration. + * @returns The block configuration. */ configuration(): Partial { - return {}; + return { + attributes: { + [ this.options.name ]: { + required: this.options.required === true, + }, + }, + }; } /** @@ -78,32 +76,6 @@ export default abstract class BlockInstruction extends Instruction { * @returns {BlockValidationResult} The validation result. */ validate( blockInstance: BlockInstance ): BlockValidationResult { - const issues: BlockValidationResult[] = []; - - if ( this.options ) { - const presence = this.options.required ? BlockPresence.Required : BlockPresence.Recommended; - const attributeValid = attributeExists( blockInstance, this.options.name as string ) && - attributeNotEmpty( blockInstance, this.options.name as string ); - if ( ! attributeValid ) { - issues.push( BlockValidationResult.MissingAttribute( blockInstance, this.constructor.name, presence ) ); - } - } - - if ( blockInstance.name.startsWith( "core/" ) && ! blockInstance.isValid ) { - issues.push( new BlockValidationResult( blockInstance.clientId, this.constructor.name, BlockValidation.Invalid, BlockPresence.Unknown ) ); - } - - // No issues found? That means the block is valid. - if ( issues.length < 1 ) { - return BlockValidationResult.Valid( blockInstance, this.constructor.name ); - } - - // Make sure to report the worst case scenario as the final validation result. - const worstCase: BlockValidationResult = maxBy( issues, issue => issue.result ); - - const validation = new BlockValidationResult( blockInstance.clientId, this.constructor.name, worstCase.result, worstCase.blockPresence ); - validation.issues = issues; - - return validation; + return new BlockValidationResult( blockInstance.clientId, this.constructor.name, BlockValidation.Unknown, BlockPresence.Unknown ); } } diff --git a/packages/schema-blocks/src/core/blocks/BlockInstructionClass.ts b/packages/schema-blocks/src/core/blocks/BlockInstructionClass.ts new file mode 100644 index 000000000..6949d0b7a --- /dev/null +++ b/packages/schema-blocks/src/core/blocks/BlockInstructionClass.ts @@ -0,0 +1,7 @@ +import { InstructionOptions } from "../Instruction"; +import BlockInstruction from "./BlockInstruction"; + +export type BlockInstructionClass = { + new( id: number, options: InstructionOptions ): BlockInstruction; + options: InstructionOptions; +}; diff --git a/packages/schema-blocks/src/core/blocks/index.ts b/packages/schema-blocks/src/core/blocks/index.ts new file mode 100644 index 000000000..212afa934 --- /dev/null +++ b/packages/schema-blocks/src/core/blocks/index.ts @@ -0,0 +1,13 @@ +import BlockDefinition from "./BlockDefinition"; +import BlockInstruction from "./BlockInstruction"; +import { BlockInstructionClass } from "./BlockInstructionClass"; +import BlockLeaf from "./BlockLeaf"; +import * as BlockDefinitionRepository from "./BlockDefinitionRepository"; + +export { + BlockDefinition, + BlockInstruction, + BlockInstructionClass, + BlockLeaf, + BlockDefinitionRepository, +}; diff --git a/packages/schema-blocks/src/core/validation/BlockValidation.ts b/packages/schema-blocks/src/core/validation/BlockValidation.ts index 5cd8ffd31..bed354739 100644 --- a/packages/schema-blocks/src/core/validation/BlockValidation.ts +++ b/packages/schema-blocks/src/core/validation/BlockValidation.ts @@ -30,5 +30,5 @@ export enum BlockValidation { /** This block contains a Variationpicker to choose between subblocks, but no choice has been made yet for this recommended block. */ MissingRequiredVariation = 303, /** There may be only one of this type of block, but we found more than one. */ - TooMany = 303, + TooMany = 304, } diff --git a/packages/schema-blocks/src/core/validation/BlockValidationResult.ts b/packages/schema-blocks/src/core/validation/BlockValidationResult.ts index 5729079aa..81b905f74 100644 --- a/packages/schema-blocks/src/core/validation/BlockValidationResult.ts +++ b/packages/schema-blocks/src/core/validation/BlockValidationResult.ts @@ -53,7 +53,6 @@ export class BlockValidationResult { constructor( clientId: string, name: string, result: BlockValidation, blockPresence: BlockPresence, message?: string ) { this.name = name; this.clientId = clientId; - this.name = name; this.result = result; this.blockPresence = blockPresence; this.issues = []; @@ -70,14 +69,35 @@ export class BlockValidationResult { * @constructor */ static MissingAttribute( blockInstance: BlockInstance, name?: string, blockPresence?: BlockPresence ) { - const blockValidation: BlockValidation = ( blockPresence === BlockPresence.Required ) - ? BlockValidation.MissingRequiredAttribute : BlockValidation.MissingRecommendedAttribute; + let blockValidation: BlockValidation = BlockValidation.Unknown; + let message = ""; + + switch ( blockPresence ) { + case BlockPresence.Required : + blockValidation = BlockValidation.MissingRequiredAttribute; + message = sprintf( + /* Translators: %1$s expands to the block name. */ + __( "The `%1$s` attribute is required but missing.", "yoast-schema-blocks" ), + name, + ); + break; + + case BlockPresence.Recommended : + blockValidation = BlockValidation.MissingRecommendedAttribute; + message = sprintf( + /* Translators: %1$s expands to the block name. */ + __( "The `%1$s` attribute is recommended but missing.", "yoast-schema-blocks" ), + name, + ); + break; + } return new BlockValidationResult( blockInstance.clientId, name || blockInstance.name, blockValidation, blockPresence || BlockPresence.Unknown, + message, ); } @@ -85,13 +105,13 @@ export class BlockValidationResult { * Named constructor for a 'missing recommended / required block' validation result. * * @param name The name of the missing block. - * @param [blockPresence] The block presence. + * @param blockPresence The block presence. * * @constructor */ static MissingBlock( name: string, blockPresence?: BlockPresence ) { if ( blockPresence === BlockPresence.Recommended ) { - return BlockValidationResult.MissingRecommendedBlock( name ); + return BlockValidationResult.MissingRecommendedBlock( name, blockPresence === BlockPresence.Recommended ); } return new BlockValidationResult( @@ -111,15 +131,16 @@ export class BlockValidationResult { * Named constructor for a 'missing recommended block' validation result. * * @param name The name of the missing block. + * @param recommended Wether the block is recommended or optional. * * @constructor */ - private static MissingRecommendedBlock( name: string ) { + private static MissingRecommendedBlock( name: string, recommended: boolean ) { return new BlockValidationResult( null, name, BlockValidation.MissingRecommendedBlock, - BlockPresence.Recommended, + recommended ? BlockPresence.Recommended : BlockPresence.Unknown, sprintf( /* Translators: %1$s expands to the block name. */ __( "The `%1$s` block is recommended but missing.", "yoast-schema-blocks" ), diff --git a/packages/schema-blocks/src/core/validation/index.ts b/packages/schema-blocks/src/core/validation/index.ts index f8c50ca01..c9742ffce 100644 --- a/packages/schema-blocks/src/core/validation/index.ts +++ b/packages/schema-blocks/src/core/validation/index.ts @@ -5,4 +5,12 @@ import { RequiredBlock } from "./RequiredBlock"; import { RecommendedBlock } from "./RecommendedBlock"; import { SuggestedBlockProperties } from "./SuggestedBlockProperties"; -export { BlockPresence, BlockValidation, BlockValidationResult, RequiredBlockOption, RequiredBlock, RecommendedBlock, SuggestedBlockProperties }; +export { + BlockPresence, + BlockValidation, + BlockValidationResult, + RecommendedBlock, + RequiredBlock, + RequiredBlockOption, + SuggestedBlockProperties, +}; diff --git a/packages/schema-blocks/src/functions/gutenberg/storeBlockValidation.ts b/packages/schema-blocks/src/functions/gutenberg/storeBlockValidation.ts index 5619f1150..005fe32e3 100644 --- a/packages/schema-blocks/src/functions/gutenberg/storeBlockValidation.ts +++ b/packages/schema-blocks/src/functions/gutenberg/storeBlockValidation.ts @@ -12,8 +12,6 @@ export default function storeBlockValidation( validations: BlockValidationResult return; } - logger.debug( "Updating the store with the validation results." ); - const store = dispatch( YOAST_SCHEMA_BLOCKS_STORE_NAME ); if ( store ) { store.resetBlockValidation(); @@ -22,6 +20,6 @@ export default function storeBlockValidation( validations: BlockValidationResult store.addBlockValidation( blockValidation ); } ); } else { - logger.debug( "No Store!" ); + logger.debug( "No Store! Cannot store validations." ); } } diff --git a/packages/schema-blocks/src/functions/presenters/BlockSuggestionsPresenter.tsx b/packages/schema-blocks/src/functions/presenters/BlockSuggestionsPresenter.tsx index 3356ee8ec..4e731d047 100644 --- a/packages/schema-blocks/src/functions/presenters/BlockSuggestionsPresenter.tsx +++ b/packages/schema-blocks/src/functions/presenters/BlockSuggestionsPresenter.tsx @@ -1,47 +1,60 @@ +import { get } from "lodash"; +import { YOAST_SCHEMA_BLOCKS_STORE_NAME } from "../redux"; import { ReactElement } from "react"; -import { BlockInstance, createBlock } from "@wordpress/blocks"; -import { createElement, Fragment } from "@wordpress/element"; +import { createBlock } from "@wordpress/blocks"; +import { PanelBody } from "@wordpress/components"; +import { withSelect } from "@wordpress/data"; +import { createElement } from "@wordpress/element"; +import { insertBlock } from "../innerBlocksHelper"; +import { isEmptyResult, isMissingResult, isValidResult } from "../validators/validateResults"; +import { BlockValidationResult } from "../../core/validation"; +import logger from "../logger"; -import { getBlockType } from "../BlockHelper"; -import { getInnerblocksByName, insertBlock } from "../innerBlocksHelper"; - -type BlockSuggestionAddedDto = { +type BlockSuggestionAddedProps = { blockTitle: string; + isValid: boolean; } -type BlockSuggestionDto = { - blockTitle: string; - blockName: string; - blockClientId: string; +type BlockSuggestionProps = { + suggestedBlockTitle: string; + suggestedBlockName: string; + parentBlockClientId: string; } -interface BlockSuggestionsProps { +export type SuggestionDetails = BlockValidationResult & { title: string; - block: BlockInstance; - suggestions: string[]; +} + +export interface BlockSuggestionsProps { + heading: string; + parentClientId: string; + blockNames: string[]; +} + +export interface SuggestionsProps extends BlockSuggestionsProps { + suggestions: SuggestionDetails[]; } /** * Renders a block suggestion with the possibility to add one. * - * @param {string} blockTitle The title to show. - * @param {string} blockName The name of the block to add. - * @param {string} blockClientId The clientId of the target to add the block to. + * @param blockTitle The title to show. + * @param suggestedBlockName The name of the block to add. + * @param parentBlockClientId The clientId of the target to add the block to. * - * @returns {ReactElement} The rendered block suggestion. + * @returns The rendered block suggestion. */ -function BlockSuggestion( { blockTitle, blockName, blockClientId }: BlockSuggestionDto ): ReactElement { +function BlockSuggestion( { suggestedBlockTitle, suggestedBlockName, parentBlockClientId }: BlockSuggestionProps ): ReactElement { /** * Onclick handler for the remove block. */ const addBlockClick = () => { - const blockToAdd = createBlock( blockName ); - insertBlock( blockToAdd, blockClientId ); + const blockToAdd = createBlock( suggestedBlockName ); + insertBlock( blockToAdd, parentBlockClientId ); }; - return ( -
  • - { blockTitle } +
  • + { suggestedBlockTitle }
  • ); @@ -50,67 +63,96 @@ function BlockSuggestion( { blockTitle, blockName, blockClientId }: BlockSuggest /** * Renders a block suggestion that has already been added. * - * @param {string} blockTitle The block title. + * @param blockTitle The block title. + * @param isValid Is the added block valid. * - * @returns {ReactElement} The rendered element. + * @returns The rendered element. */ -function BlockSuggestionAdded( { blockTitle }: BlockSuggestionAddedDto ): ReactElement { - const heroIconCheck: JSX.Element = - - - ; +function BlockSuggestionAdded( { blockTitle, isValid }: BlockSuggestionAddedProps ): ReactElement { + const heroIconCheck = + + ; + + const checkmark = { heroIconCheck }; return (
  • { blockTitle } - { heroIconCheck } + { isValid ? checkmark : null }
  • ); } /** - * Renders a list of suggested blocks. - * - * @param props The props. - * - * @returns The block suggestions element. + * Renders a list of block suggestions. + * @param props The BlockValidationResults and the Blocks' titles. + * @returns The appropriate Block Suggestion elements. */ -export default function BlockSuggestionsPresenter( { title, block, suggestions }: BlockSuggestionsProps ) { - const suggestedBlockNames = suggestions - .filter( suggestedBlock => typeof getBlockType( suggestedBlock ) !== "undefined" ); - - // When there are no suggestions, just return. - if ( suggestedBlockNames.length === 0 ) { +export function PureBlockSuggestionsPresenter( { heading, parentClientId, suggestions, blockNames }: SuggestionsProps ): ReactElement { + if ( ! suggestions || suggestions.length < 1 || ! blockNames || blockNames.length < 1 ) { return null; } - const findPresentBlocks = getInnerblocksByName( block, suggestedBlockNames ); - const presentBlockNames = findPresentBlocks.map( presentBlock => presentBlock.name ); - return ( - -
    { title }
    + +
    { heading }
      { - suggestedBlockNames.map( ( blockName: string, index: number ) => { - const blockType = getBlockType( blockName ); + blockNames.map( ( blockName, index: number ) => { + const suggestion = suggestions.find( sug => sug.name === blockName ); + + const isValid = suggestion && isValidResult( suggestion.result ); + const isMissing = suggestion && isMissingResult( suggestion.result ); + const isEmpty = suggestion && isEmptyResult( suggestion.result ); + + if ( isValid || isEmpty ) { + // Show the validation result. + return ; + } - if ( presentBlockNames.includes( blockName ) ) { - return ; + if ( isMissing ) { + // Show the suggestion to add an instance of this block. + return ; } - return ; - } ) + logger.debug( "No use case for block ", blockName ); + }, this ) }
    -
    + ); } + +/** + * Appends metadata to validation results retreived from the store. + * + * @param props The props containing the parent clientId and blocknames we're interested in. + * + * @returns The props extended with suggestion data. + */ +export default withSelect, BlockSuggestionsProps, SuggestionsProps>( ( select, props: BlockSuggestionsProps ) => { + const validations: BlockValidationResult[] = + select( YOAST_SCHEMA_BLOCKS_STORE_NAME ).getInnerblockValidations( props.parentClientId, props.blockNames ); + + const suggestionDetails = validations.map( validation => { + const type = select( "core/blocks" ).getBlockType( validation.name ); + return { + title: get( type, "title", "" ), + ...validation, + }; + } ); + + // The return object also includes properties from props. + return { + suggestions: suggestionDetails, + }; +} )( PureBlockSuggestionsPresenter ); diff --git a/packages/schema-blocks/src/functions/presenters/InnerBlocksSidebar.tsx b/packages/schema-blocks/src/functions/presenters/InnerBlocksSidebar.tsx index e9491aef7..e78530931 100644 --- a/packages/schema-blocks/src/functions/presenters/InnerBlocksSidebar.tsx +++ b/packages/schema-blocks/src/functions/presenters/InnerBlocksSidebar.tsx @@ -1,16 +1,14 @@ import { ReactElement } from "react"; - -import { createElement, Fragment } from "@wordpress/element"; -import { useSelect } from "@wordpress/data"; import { BlockInstance } from "@wordpress/blocks"; +import { useSelect } from "@wordpress/data"; +import { createElement, Fragment } from "@wordpress/element"; import { __ } from "@wordpress/i18n"; - import { SvgIcon } from "@yoast/components"; - import { createAnalysisMessages, SidebarWarning } from "./SidebarWarningPresenter"; -import { ClientIdValidation, YOAST_SCHEMA_BLOCKS_STORE_NAME } from "../redux"; +import { YOAST_SCHEMA_BLOCKS_STORE_NAME } from "../redux"; import BlockSuggestions from "./BlockSuggestionsPresenter"; import { BlockValidationResult } from "../../core/validation"; +import logger from "../logger"; interface InnerBlocksSidebarProps { currentBlock: BlockInstance; @@ -27,26 +25,10 @@ interface InnerBlocksSidebarProps { */ function useValidationResults( clientId: string ): BlockValidationResult { return useSelect( select => { - const results: ClientIdValidation = select( YOAST_SCHEMA_BLOCKS_STORE_NAME ).getSchemaBlocksValidationResults(); - if ( ! results ) { - return null; - } - - return results[ clientId ]; + return select( YOAST_SCHEMA_BLOCKS_STORE_NAME ).getValidationResultForClientId( clientId ); }, [ clientId ] ); } -/** - * Retrieves the latest block version from the WordPress store. - * - * @param clientId The client ID of the block to retrieve the latest version of. - * - * @returns The latest version of the block. - */ -function useBlock( clientId: string ): BlockInstance { - return useSelect( select => select( "core/block-editor" ).getBlock( clientId ), [ clientId ] ); -} - /** * Inner blocks sidebar component. * @@ -57,27 +39,27 @@ function useBlock( clientId: string ): BlockInstance { * @constructor */ export function InnerBlocksSidebar( props: InnerBlocksSidebarProps ): ReactElement { - const block = useBlock( props.currentBlock.clientId ); const validationResults = useValidationResults( props.currentBlock.clientId ); let warnings: SidebarWarning[] = []; if ( validationResults ) { warnings = createAnalysisMessages( validationResults ); + logger.debug( "Warnings:", warnings ); } - return + return ; } @@ -125,7 +107,7 @@ function WarningList( props: WarningListProps ): ReactElement {
    { __( "Analysis", "yoast-schema-blocks" ) }
      - { ...props.warnings.map( warning => ) } + {...props.warnings.map( warning => )}
    diff --git a/packages/schema-blocks/src/functions/presenters/SidebarWarningPresenter.ts b/packages/schema-blocks/src/functions/presenters/SidebarWarningPresenter.ts index 24a25541e..79cc767b6 100644 --- a/packages/schema-blocks/src/functions/presenters/SidebarWarningPresenter.ts +++ b/packages/schema-blocks/src/functions/presenters/SidebarWarningPresenter.ts @@ -1,13 +1,8 @@ -import { select } from "@wordpress/data"; import { __, sprintf } from "@wordpress/i18n"; -import { YOAST_SCHEMA_BLOCKS_STORE_NAME } from "../redux"; -import { BlockValidation, BlockValidationResult } from "../../core/validation"; +import { BlockValidation, BlockValidationResult, BlockPresence } from "../../core/validation"; import { getHumanReadableBlockName } from "../BlockHelper"; -import { BlockPresence } from "../../core/validation/BlockValidationResult"; import { getAllDescendantIssues } from "../validators"; -type clientIdValidation = Record; - /** * A warning message for in the sidebar schema analysis. */ @@ -23,22 +18,6 @@ export type SidebarWarning = { color: "red" | "orange" | "green"; } -/** - * Gets the validation results from the store for a block instance with the given clientId. - * - * @param clientId The clientId to request validation results for. - * - * @returns {BlockValidationResult} The validation results, or null if none were found. - */ -function getValidationResult( clientId: string ): BlockValidationResult | null { - const validationResults: clientIdValidation = select( YOAST_SCHEMA_BLOCKS_STORE_NAME ).getSchemaBlocksValidationResults(); - if ( ! validationResults ) { - return null; - } - - return validationResults[ clientId ]; -} - /** * Adds analysis conclusions to the footer. * @@ -105,8 +84,11 @@ function getWarningMessages( issues: BlockValidationResult[] ): SidebarWarning[] * @returns {SidebarWarning[]} The formatted warnings. */ export function createAnalysisMessages( validation: BlockValidationResult ): SidebarWarning[] { - const issues = getAllDescendantIssues( validation ); + if ( ! validation ) { + return []; + } + const issues = getAllDescendantIssues( validation ); const messages = []; messages.push( ...getErrorMessages( issues ) ); @@ -131,19 +113,3 @@ export function sanitizeParentName( parent: string ): string { return parent.toLowerCase(); } - -/** - * Converts the validation results for a block instance with the given clientId to a presentable text. - * - * @param clientId The clientId to request validation results for. - * - * @returns {string} The presentable warning message, or null if no warnings are found. - */ -export default function getWarnings( clientId: string ): SidebarWarning[] { - const validation: BlockValidationResult = getValidationResult( clientId ); - if ( ! validation ) { - return null; - } - - return createAnalysisMessages( validation ); -} diff --git a/packages/schema-blocks/src/functions/process.ts b/packages/schema-blocks/src/functions/process.ts index 54a902220..bc38c19a8 100644 --- a/packages/schema-blocks/src/functions/process.ts +++ b/packages/schema-blocks/src/functions/process.ts @@ -11,6 +11,7 @@ import Instruction, { } from "../core/Instruction"; import SchemaDefinition from "../core/schema/SchemaDefinition"; import SchemaInstruction from "../core/schema/SchemaInstruction"; +import logger from "./logger"; import { generateUniqueSeparator } from "./separator"; import tokenize from "./tokenize"; @@ -119,6 +120,11 @@ function processBlockInstruction( token: IToken, tokens: IToken[], instr const defaultOptions = { name: token.value }; const instruction = instructionClass.create( token.value, generateNextId( separator ), defaultOptions ); + if ( ! instruction ) { + logger.error( "Could not instantiate instuctionClass " + instructionClass.name ); + return; + } + while ( tokens[ 0 ] && tokens[ 0 ].isA( "key" ) ) { const key = camelCase( ( tokens.shift() as IToken ).value ); instruction.options[ key ] = processToken( tokens[ 0 ], tokens ); diff --git a/packages/schema-blocks/src/functions/redux/selectors/schemaBlocks.ts b/packages/schema-blocks/src/functions/redux/selectors/schemaBlocks.ts index 1ab984d67..395020d32 100644 --- a/packages/schema-blocks/src/functions/redux/selectors/schemaBlocks.ts +++ b/packages/schema-blocks/src/functions/redux/selectors/schemaBlocks.ts @@ -1,5 +1,8 @@ import { SchemaBlocksState, SchemaBlocksDefaultState } from "../SchemaBlocksState"; import { BlockValidationResult } from "../../../core/validation"; +import { recursivelyFind } from "../../validators/recursivelyFind"; +import { flatMap } from "lodash"; +import logger from "../../logger"; export type ClientIdValidation = Record; @@ -13,3 +16,34 @@ export type ClientIdValidation = Record; export function getSchemaBlocksValidationResults( state: SchemaBlocksState ): ClientIdValidation { return state.validations || SchemaBlocksDefaultState.validations; } + +/** + * Recursively traverses a BlockValidationResult's issues to finds the validation results for a specific clientId. + * @param state The entire Schema Blocks state. + * @param clientId The ClientId of the block you want validation results for. + * @returns The BlockValidationResult matching the clientId or null if none were found. + */ +export function getValidationResultForClientId( state: SchemaBlocksState, clientId: string ): BlockValidationResult { + const stored = getSchemaBlocksValidationResults( state ); + logger.debug( "stored validations:", stored ); + const validationResults = Object.values( stored ); + + return recursivelyFind( validationResults, ( result ) => result.clientId === clientId ); +} + +/** + * Finds all validation results for innerblocks that match the names of given blocks. + * @param state The entire Schema Blocks state. + * @param clientId The clientId of the parent block containing the Innerblocks, e.g. the job posting id. + * @param blockNames the set of blocknames you're looking for. + * @returns The innerblock's validation. + */ +export function getInnerblockValidations( state: SchemaBlocksState, clientId: string, blockNames?: string[] ): BlockValidationResult[] { + const validation = getValidationResultForClientId( state, clientId ); + if ( ! validation || validation.issues.length <= 0 ) { + return []; + } + + return flatMap( validation.issues, issue => issue.issues.filter( ( result ) => blockNames.includes( result.name ) ) ); +} + diff --git a/packages/schema-blocks/src/functions/validators/defaultValidate.ts b/packages/schema-blocks/src/functions/validators/defaultValidate.ts new file mode 100644 index 000000000..8b0030340 --- /dev/null +++ b/packages/schema-blocks/src/functions/validators/defaultValidate.ts @@ -0,0 +1,48 @@ +import { maxBy } from "lodash"; +import { BlockInstance } from "@wordpress/blocks"; +import { attributeExists, attributeNotEmpty } from "."; +import { BlockValidationResult, BlockPresence, BlockValidation } from "../../core/validation"; +import { BlockInstruction } from "../../core/blocks"; +import { getPresence } from "./getPresence"; + +/** + * Checks if the instruction block is valid. + * + * @param blockInstance The attributes from the block. + * @param thisObj The this context. + * + * @returns The validation result. + */ +function defaultValidate( blockInstance: BlockInstance, thisObj: BlockInstruction ): BlockValidationResult { + const issues: BlockValidationResult[] = []; + + let presence = BlockPresence.Unknown; + if ( thisObj.options ) { + presence = getPresence( thisObj.options ); + const attributeValid = attributeExists( blockInstance, thisObj.options.name as string ) && + attributeNotEmpty( blockInstance, thisObj.options.name as string ); + if ( ! attributeValid ) { + issues.push( BlockValidationResult.MissingAttribute( blockInstance, thisObj.constructor.name, presence ) ); + } + } + + // Core blocks have their own validation + if ( blockInstance.name.startsWith( "core/" ) && ! blockInstance.isValid ) { + issues.push( new BlockValidationResult( blockInstance.clientId, thisObj.constructor.name, BlockValidation.Invalid, presence ) ); + } + + // No issues found? That means the block is valid. + if ( issues.length < 1 ) { + return BlockValidationResult.Valid( blockInstance, thisObj.constructor.name, presence ); + } + + // Make sure to report the worst case scenario as the final validation result. + const worstCase: BlockValidationResult = maxBy( issues, issue => issue.result ); + + const validation = new BlockValidationResult( blockInstance.clientId, thisObj.constructor.name, worstCase.result, worstCase.blockPresence ); + validation.issues = issues; + + return validation; +} + +export { defaultValidate }; diff --git a/packages/schema-blocks/src/functions/validators/getAllDescendantIssues.ts b/packages/schema-blocks/src/functions/validators/getAllDescendantIssues.ts index 6814ea1ca..9da8fb9e4 100644 --- a/packages/schema-blocks/src/functions/validators/getAllDescendantIssues.ts +++ b/packages/schema-blocks/src/functions/validators/getAllDescendantIssues.ts @@ -8,10 +8,15 @@ import { BlockValidationResult } from "../../core/validation"; * @return all validation results. */ function getAllDescendantIssues( validation: BlockValidationResult ): BlockValidationResult[] { + if ( ! validation ) { + return []; + } + let results = [ validation ]; validation.issues.forEach( issue => { results = results.concat( getAllDescendantIssues( issue ) ); } ); + return results; } diff --git a/packages/schema-blocks/src/functions/validators/getPresence.ts b/packages/schema-blocks/src/functions/validators/getPresence.ts new file mode 100644 index 000000000..3b5d87777 --- /dev/null +++ b/packages/schema-blocks/src/functions/validators/getPresence.ts @@ -0,0 +1,21 @@ +import { BlockPresence } from "../../core/validation"; +import { InstructionOptions } from "../../core/Instruction"; + +/** + * Converts the presence requirements of a particular element to a BlockPresence variable. + * @param options The block's options. + * @returns The requirements converted to BlockPresence. + */ +export function getPresence( options: InstructionOptions ) { + if ( ! options || options.required === "undefined" ) { + return BlockPresence.Unknown; + } + + if ( options.required === true ) { + return BlockPresence.Required; + } + + if ( options.required === false ) { + return BlockPresence.Recommended; + } +} diff --git a/packages/schema-blocks/src/functions/validators/getValidationResult.ts b/packages/schema-blocks/src/functions/validators/getValidationResult.ts new file mode 100644 index 000000000..529b8e5d6 --- /dev/null +++ b/packages/schema-blocks/src/functions/validators/getValidationResult.ts @@ -0,0 +1,52 @@ +import { BlockValidationResult } from "../../core/validation"; +import { select } from "@wordpress/data"; +import { YOAST_SCHEMA_BLOCKS_STORE_NAME } from "../redux"; + +type clientIdValidation = Record; + +/** + * Gets the validation results from the store for a block instance with the given clientId. + * + * @param clientId The clientId to request validation results for. + * + * @returns The validation results, or null if none were found. + */ +export function getValidationResults(): BlockValidationResult[] { + const validationResults: clientIdValidation = select( YOAST_SCHEMA_BLOCKS_STORE_NAME ).getSchemaBlocksValidationResults(); + if ( ! validationResults ) { + return []; + } + + const validations: BlockValidationResult[] = Object.values( validationResults ); + return validations; +} + +/** + * Recursively traverses a BlockValidationResult's issues to finds the validation results for a specific clientId. + * @param clientId The ClientId of the block you want validation results for. + * @param validationResults The (partial) ValidationResult tree to investigate; reads the entire tree from the store by default. + * @returns The BlockValidationResult matching the clientId or null if none were found. + */ +export function getValidationResultForClientId( clientId: string, validationResults?: BlockValidationResult[] ): BlockValidationResult { + if ( ! validationResults ) { + validationResults = getValidationResults(); + } + + for ( const validationResult of validationResults ) { + // When the validation result matches the client id, return it. + if ( validationResult.clientId === clientId ) { + return validationResult; + } + + // Just keep driving down the tree calling until we have found the result. + if ( validationResult.issues.length > 0 ) { + const validation = getValidationResultForClientId( clientId, validationResult.issues ); + if ( validation ) { + return validation; + } + } + } + + // We haven't found the result down this tree. + return null; +} diff --git a/packages/schema-blocks/src/functions/validators/index.ts b/packages/schema-blocks/src/functions/validators/index.ts index e00160052..46fc96d1c 100644 --- a/packages/schema-blocks/src/functions/validators/index.ts +++ b/packages/schema-blocks/src/functions/validators/index.ts @@ -2,10 +2,13 @@ import attributeExists from "./attributeExists"; import attributeNotEmpty from "./attributeNotEmpty"; import getAllDescendantIssues from "./getAllDescendantIssues"; import getInvalidInnerBlocks from "./innerBlocksValid"; +import { getValidationResults, getValidationResultForClientId } from "./getValidationResult"; export { attributeExists, attributeNotEmpty, getAllDescendantIssues, getInvalidInnerBlocks, + getValidationResults, + getValidationResultForClientId, }; diff --git a/packages/schema-blocks/src/functions/validators/innerBlocksValid.ts b/packages/schema-blocks/src/functions/validators/innerBlocksValid.ts index aa3594247..7c46f14cf 100644 --- a/packages/schema-blocks/src/functions/validators/innerBlocksValid.ts +++ b/packages/schema-blocks/src/functions/validators/innerBlocksValid.ts @@ -23,7 +23,9 @@ import { getHumanReadableBlockName } from "../BlockHelper"; * * @returns {BlockValidationResult[]} The names of blocks that should/could occur but don't, with reason 'MissingBlock'. */ -function findMissingBlocks( existingBlocks: BlockInstance[], wantedBlocks: RequiredBlock[] | RecommendedBlock[], +function findMissingBlocks( + existingBlocks: BlockInstance[], + wantedBlocks: RequiredBlock[] | RecommendedBlock[], blockPresence: BlockPresence ): BlockValidationResult[] { const missingBlocks = wantedBlocks.filter( block => { // If, in the existing blocks, there are not any blocks with the name of block, that block is missing. @@ -45,8 +47,10 @@ function findMissingBlocks( existingBlocks: BlockInstance[], wantedBlocks: Requi * * @returns {BlockValidationResult[]} The names of blocks that occur more than once in the inner blocks with reason 'TooMany'. */ -function findRedundantBlocks( existingBlocks: BlockInstance[], allBlocks: RequiredBlock[] | RecommendedBlock[], - blockPresence: BlockPresence ): BlockValidationResult[] { +function findRedundantBlocks( + existingBlocks: BlockInstance[], + allBlocks: RequiredBlock[] | RecommendedBlock[], + blockPresence: BlockPresence ): BlockValidationResult[] { const validationResults: BlockValidationResult[] = []; const singletons = allBlocks.filter( block => block.option !== RequiredBlockOption.Multiple ); diff --git a/packages/schema-blocks/src/functions/validators/recursivelyFind.ts b/packages/schema-blocks/src/functions/validators/recursivelyFind.ts new file mode 100644 index 000000000..85a649845 --- /dev/null +++ b/packages/schema-blocks/src/functions/validators/recursivelyFind.ts @@ -0,0 +1,26 @@ +import { BlockValidationResult } from "../../core/validation"; + +/** + * Recursively traverses a BlockValidationResult's issues to finds the validation results for a specific clientId. + * @param source The validation results to process. + * @param predicate The predicate that determines wether to filter the validation or not. + * @returns The BlockValidationResult matching the clientId or null if none were found. + */ +export function recursivelyFind( source: BlockValidationResult[], predicate: ( source: BlockValidationResult ) => boolean ): BlockValidationResult { + for ( const validationResult of source ) { + // When the validation result matches the client id, return it. + if ( predicate( validationResult ) ) { + return validationResult; + } + + // Just keep driving down the tree calling until we have found the result. + if ( validationResult.issues.length > 0 ) { + const validation = recursivelyFind( validationResult.issues, predicate ); + if ( validation ) { + return validation; + } + } + } + // We haven't found the result down this tree. + return null; +} diff --git a/packages/schema-blocks/src/functions/validators/validateResults.ts b/packages/schema-blocks/src/functions/validators/validateResults.ts index 8b1e94744..19c326936 100644 --- a/packages/schema-blocks/src/functions/validators/validateResults.ts +++ b/packages/schema-blocks/src/functions/validators/validateResults.ts @@ -1,47 +1,75 @@ import { BlockValidation } from "../../core/validation"; /** - * Determines if a specific validation result is valid. + * Determines if a specific validation source is valid. * - * @param result The source value. + * @param source The source value. * - * @returns Whether the result is valid. + * @returns Whether the source is valid. */ -function isValidResult( result: BlockValidation ): boolean { - return result < 200; +export function isValidResult( source: BlockValidation ): boolean { + return source < 200; } /** - * Determines if the result should lead to Schema output. + * Determines if a specific validation indicates if an element is missing. * - * @param result The source value. + * @param source The validation to check. * - * @returns Whether the result should lead to Schema output. + * @returns Wether the validation found a missing element. */ -function isResultValidForSchema( result: BlockValidation ): boolean { - return result < 300; +export function isMissingResult( source: BlockValidation ): boolean { + return [ + BlockValidation.MissingRecommendedBlock, + BlockValidation.MissingRequiredBlock, + ].includes( source ); } /** - * Determines if the result is OK (in other words, would lead to an orange bullet). + * Determines if a specific validation indicates if an element is present, but empty. * - * @param result The source value. + * @param source The validation to check. * - * @returns Whether the result is OK. + * @returns Wether the validation found an empty element. */ -function isOkResult( result: BlockValidation ): boolean { - return result >= 200 && result < 300; +export function isEmptyResult( source: BlockValidation ): boolean { + return [ + BlockValidation.MissingRecommendedAttribute, + BlockValidation.MissingRequiredAttribute, + BlockValidation.MissingRecommendedVariation, + BlockValidation.MissingRequiredVariation, + ].includes( source ); } /** - * Determines if the result is invalid. + * Determines if the source should lead to Schema output. * - * @param result The source value. + * @param source The source value. * - * @returns Whether the result is invalid. + * @returns Whether the source should lead to Schema output. */ -function isInvalidResult( result: BlockValidation ): boolean { - return result >= 300; +export function isResultValidForSchema( source: BlockValidation ): boolean { + return source < 300; } -export { isValidResult, isResultValidForSchema, isOkResult, isInvalidResult }; +/** + * Determines if the source is OK (in other words, would lead to an orange bullet). + * + * @param source The source value. + * + * @returns Whether the source is OK. + */ +export function isOkResult( source: BlockValidation ): boolean { + return source >= 200 && source < 300; +} + +/** + * Determines if the source is invalid. + * + * @param source The source value. + * + * @returns Whether the source is invalid. + */ +export function isInvalidResult( source: BlockValidation ): boolean { + return source >= 300; +} diff --git a/packages/schema-blocks/src/instructions/blocks/Date.tsx b/packages/schema-blocks/src/instructions/blocks/Date.tsx index a86a26f06..2a9889792 100644 --- a/packages/schema-blocks/src/instructions/blocks/Date.tsx +++ b/packages/schema-blocks/src/instructions/blocks/Date.tsx @@ -1,14 +1,14 @@ -// External imports. -import { BlockConfiguration } from "@wordpress/blocks"; +import { BlockConfiguration, BlockInstance } from "@wordpress/blocks"; import { DateTimePicker, Dropdown } from "@wordpress/components"; import { createElement, useState } from "@wordpress/element"; import { __experimentalGetSettings, dateI18n, format } from "@wordpress/date"; import { __ } from "@wordpress/i18n"; - -// Internal imports. import BlockInstruction from "../../core/blocks/BlockInstruction"; import { RenderEditProps, RenderSaveProps } from "../../core/blocks/BlockDefinition"; import { useCallback } from "react"; +import { BlockValidationResult } from "../.."; +import { getPresence } from "../../functions/validators/getPresence"; +import { BlockPresence, BlockValidation } from "../../core/validation"; /** * Adds a date picker to the schema block. @@ -16,6 +16,7 @@ import { useCallback } from "react"; export default class Date extends BlockInstruction { options: { name: string; + required?: boolean; }; /** @@ -142,6 +143,31 @@ export default class Date extends BlockInstruction { }, }; } + + /** + * Checks if the instruction block is valid. + * + * @param blockInstance The attributes from the block. + * + * @returns {BlockValidationResult} The validation result. + */ + validate( blockInstance: BlockInstance ): BlockValidationResult { + const date = blockInstance.attributes[ this.options.name ] as string; + const presence = getPresence( this.options ); + + let validation = BlockValidation.Unknown; + if ( date && date.trim().length > 0 ) { + validation = BlockValidation.Valid; + } else { + if ( presence === BlockPresence.Required ) { + validation = BlockValidation.MissingRequiredAttribute; + } else { + validation = BlockValidation.MissingRecommendedAttribute; + } + } + + return new BlockValidationResult( blockInstance.clientId, this.constructor.name, validation, presence ); + } } BlockInstruction.register( "date", Date ); diff --git a/packages/schema-blocks/src/instructions/blocks/Heading.tsx b/packages/schema-blocks/src/instructions/blocks/Heading.tsx index 1c9f196b9..fff6152e4 100644 --- a/packages/schema-blocks/src/instructions/blocks/Heading.tsx +++ b/packages/schema-blocks/src/instructions/blocks/Heading.tsx @@ -1,13 +1,14 @@ +import { Fragment } from "react"; import { BlockControls, RichText as WordPressRichText } from "@wordpress/block-editor"; -import { BlockConfiguration } from "@wordpress/blocks"; +import { BlockConfiguration, BlockInstance } from "@wordpress/blocks"; import { createElement } from "@wordpress/element"; -import { Fragment } from "react"; -import BlockInstruction from "../../core/blocks/BlockInstruction"; +import { BlockInstruction, BlockLeaf } from "../../core/blocks"; import { RenderEditProps, RenderSaveProps } from "../../core/blocks/BlockDefinition"; +import { BlockValidationResult } from "../../core/validation"; import RichTextBase, { RichTextEditProps, RichTextSaveProps } from "./abstract/RichTextBase"; -import BlockLeaf from "../../core/blocks/BlockLeaf"; import HeadingLevelDropdown from "../../functions/presenters/HeadingLevelDropdown"; +import { defaultValidate } from "../../functions/validators/defaultValidate"; /** @@ -25,6 +26,7 @@ export class Heading extends RichTextBase { multiline: boolean; label: string; value: string; + required?: boolean; }; /** @@ -40,6 +42,7 @@ export class Heading extends RichTextBase { source: "html", "default": this.options.default, selector: `[data-id=${this.options.name}]`, + required: this.options.required === true, }, [ this.options.name + "_level" ]: { type: "number", @@ -121,6 +124,17 @@ export class Heading extends RichTextBase { key: i, }; } + + /** + * Checks if the instruction block is valid. + * + * @param blockInstance The attributes from the block. + * + * @returns {BlockValidationResult} The validation result. + */ + validate( blockInstance: BlockInstance ): BlockValidationResult { + return defaultValidate( blockInstance, this ); + } } BlockInstruction.register( "heading", Heading ); diff --git a/packages/schema-blocks/src/instructions/blocks/InnerBlocks.tsx b/packages/schema-blocks/src/instructions/blocks/InnerBlocks.tsx index 851e78f04..9baee24db 100644 --- a/packages/schema-blocks/src/instructions/blocks/InnerBlocks.tsx +++ b/packages/schema-blocks/src/instructions/blocks/InnerBlocks.tsx @@ -2,16 +2,15 @@ import { maxBy } from "lodash"; import { ComponentType, ReactElement } from "react"; import { createElement, Fragment } from "@wordpress/element"; import { InnerBlocks as WordPressInnerBlocks } from "@wordpress/block-editor"; -import BlockAppender from "../../functions/presenters/BlockAppender"; import { BlockInstance } from "@wordpress/blocks"; -import { BlockValidationResult } from "../../core/validation"; -import BlockInstruction from "../../core/blocks/BlockInstruction"; -import validateInnerBlocks from "../../functions/validators/innerBlocksValid"; +import { BlockInstruction, BlockLeaf } from "../../core/blocks"; import { RenderEditProps, RenderSaveProps } from "../../core/blocks/BlockDefinition"; +import { BlockValidationResult } from "../../core/validation"; import { getBlockByClientId } from "../../functions/BlockHelper"; -import { InnerBlocksInstructionOptions } from "./InnerBlocksInstructionOptions"; -import BlockLeaf from "../../core/blocks/BlockLeaf"; +import BlockAppender from "../../functions/presenters/BlockAppender"; import { InnerBlocksSidebar } from "../../functions/presenters/InnerBlocksSidebar"; +import validateInnerBlocks from "../../functions/validators/innerBlocksValid"; +import { InnerBlocksInstructionOptions } from "./InnerBlocksInstructionOptions"; /** * Custom props for InnerBlocks. @@ -129,7 +128,7 @@ export default class InnerBlocks extends BlockInstruction { recommendedBlockNames = this.options.recommendedBlocks.map( block => block.name ); } - if ( ! ( requiredBlockNames.length > 0 && recommendedBlockNames.length > 0 ) ) { + if ( requiredBlockNames.length < 1 && recommendedBlockNames.length < 1 ) { return null; } diff --git a/packages/schema-blocks/src/instructions/blocks/Select.tsx b/packages/schema-blocks/src/instructions/blocks/Select.tsx index 17d2f0a6e..049f177b3 100644 --- a/packages/schema-blocks/src/instructions/blocks/Select.tsx +++ b/packages/schema-blocks/src/instructions/blocks/Select.tsx @@ -1,10 +1,10 @@ import { createElement, ReactElement, useCallback } from "@wordpress/element"; -import { BlockConfiguration, BlockInstance } from "@wordpress/blocks"; +import { BlockInstance } from "@wordpress/blocks"; import { SelectControl } from "@wordpress/components"; - -import BlockInstruction from "../../core/blocks/BlockInstruction"; +import { BlockInstruction } from "../../core/blocks/"; import { RenderEditProps, RenderSaveProps } from "../../core/blocks/BlockDefinition"; -import { attributeExists, attributeNotEmpty } from "../../functions/validators"; +import { BlockValidationResult } from "../../core/validation"; +import { defaultValidate } from "../../functions/validators/defaultValidate"; /** * Select (a drop-down box) instruction. @@ -59,6 +59,17 @@ export default class Select extends BlockInstruction { ; } + /** + * Checks if the instruction block is valid. + * + * @param blockInstance The attributes from the block. + * + * @returns {BlockValidationResult} The validation result. + */ + validate( blockInstance: BlockInstance ): BlockValidationResult { + return defaultValidate( blockInstance, this ); + } + /** * Returns the label of the selected option. * @@ -112,37 +123,6 @@ export default class Select extends BlockInstruction { hideLabelFromVision={ hideLabelFromVision } />; } - - /** - * Adds the select to the block configuration. - * - * @returns The block configuration. - */ - configuration(): Partial { - return { - attributes: { - [ this.options.name ]: { - required: this.options.required === true, - }, - }, - }; - } - - /** - * Checks if the instruction block is valid. - * - * @param blockInstance The attributes from the block. - * - * @returns `true` if the instruction block is valid, `false` if the block contains errors. - */ - valid( blockInstance: BlockInstance ): boolean { - if ( this.options.required === true ) { - return attributeExists( blockInstance, this.options.name as string ) && - attributeNotEmpty( blockInstance, this.options.name as string ); - } - - return attributeExists( blockInstance, this.options.name as string ); - } } BlockInstruction.register( "select", Select ); diff --git a/packages/schema-blocks/src/instructions/blocks/SidebarDuration.ts b/packages/schema-blocks/src/instructions/blocks/SidebarDuration.ts index 984d0768b..ac3018c5c 100644 --- a/packages/schema-blocks/src/instructions/blocks/SidebarDuration.ts +++ b/packages/schema-blocks/src/instructions/blocks/SidebarDuration.ts @@ -1,12 +1,11 @@ import moment from "moment"; import { createElement, Fragment } from "@wordpress/element"; import { TextControl } from "@wordpress/components"; - +import { BlockEditProps, BlockConfiguration } from "@wordpress/blocks"; +import { __ } from "@wordpress/i18n"; import BlockInstruction from "../../core/blocks/BlockInstruction"; import { RenderSaveProps, RenderEditProps } from "../../core/blocks/BlockDefinition"; -import { BlockEditProps, BlockConfiguration } from "@wordpress/blocks"; import SidebarBase, { SidebarBaseOptions } from "./abstract/SidebarBase"; -import { __ } from "@wordpress/i18n"; /** * Updates a duration. diff --git a/packages/schema-blocks/src/instructions/blocks/TextInput.tsx b/packages/schema-blocks/src/instructions/blocks/TextInput.tsx index ad1d2061b..11cd7b2a3 100644 --- a/packages/schema-blocks/src/instructions/blocks/TextInput.tsx +++ b/packages/schema-blocks/src/instructions/blocks/TextInput.tsx @@ -1,10 +1,9 @@ +import { useCallback } from "react"; import { TextControl } from "@wordpress/components"; import { createElement } from "@wordpress/element"; - +import { BlockConfiguration } from "@wordpress/blocks"; import BlockInstruction from "../../core/blocks/BlockInstruction"; import { RenderEditProps, RenderSaveProps } from "../../core/blocks/BlockDefinition"; -import { useCallback } from "react"; -import { BlockConfiguration } from "@wordpress/blocks"; /** * The text input instruction. diff --git a/packages/schema-blocks/src/instructions/blocks/Title.tsx b/packages/schema-blocks/src/instructions/blocks/Title.tsx index 9ec06caa7..bcf92d869 100644 --- a/packages/schema-blocks/src/instructions/blocks/Title.tsx +++ b/packages/schema-blocks/src/instructions/blocks/Title.tsx @@ -7,6 +7,7 @@ import { BlockValidation, BlockValidationResult } from "../../core/validation"; import BlockInstruction from "../../core/blocks/BlockInstruction"; import { BlockPresence } from "../../core/validation/BlockValidationResult"; import { attributeExists, attributeNotEmpty } from "../../functions/validators"; +import { getPresence } from "../../functions/validators/getPresence"; /** * Interface for a WordPress post object. @@ -60,7 +61,7 @@ class Title extends Heading { const postTitle: string = select( "core/editor" ).getEditedPostAttribute( "title" ); if ( ! this.isCompleted( blockInstance ) ) { - const presence = this.options.required === true ? BlockPresence.Required : BlockPresence.Recommended; + const presence = getPresence( this.options ); return BlockValidationResult.MissingAttribute( blockInstance, this.constructor.name, presence ); } diff --git a/packages/schema-blocks/src/instructions/blocks/VariationPicker.tsx b/packages/schema-blocks/src/instructions/blocks/VariationPicker.tsx index 221a38480..8d01073aa 100644 --- a/packages/schema-blocks/src/instructions/blocks/VariationPicker.tsx +++ b/packages/schema-blocks/src/instructions/blocks/VariationPicker.tsx @@ -1,13 +1,12 @@ -import BlockInstruction from "../../core/blocks/BlockInstruction"; import { useSelect } from "@wordpress/data"; -import { RenderEditProps } from "../../core/blocks/BlockDefinition"; -import BlockLeaf from "../../core/blocks/BlockLeaf"; import { BlockInstance } from "@wordpress/blocks"; -// eslint-disable-next-line @typescript-eslint/no-unused-vars import { createElement } from "@wordpress/element"; +import { BlockLeaf, BlockInstruction } from "../../core/blocks"; +import { RenderEditProps } from "../../core/blocks/BlockDefinition"; import { BlockPresence, BlockValidation, BlockValidationResult } from "../../core/validation"; import VariationPickerPresenter from "../../functions/presenters/VariationPickerPresenter"; import { getParent } from "../../functions/gutenberg/block"; +import { getPresence } from "../../functions/validators/getPresence"; /** * Helper function to check whether the block instance includes a picked variation. @@ -23,7 +22,7 @@ function includesAVariation( blockInstance: BlockInstance ): boolean { /** * VariationPicker instruction. */ -class VariationPicker extends BlockInstruction { +export class VariationPicker extends BlockInstruction { /** * Renders the variation picker if the block doesn't have any inner blocks. * Otherwise, renders null. @@ -56,7 +55,7 @@ class VariationPicker extends BlockInstruction { * @returns {BlockValidationResult} The validation result. */ validate( blockInstance: BlockInstance ): BlockValidationResult { - const presence: BlockPresence = this.options.required ? BlockPresence.Required : BlockPresence.Recommended; + const presence = getPresence( this.options ); const parent = getParent( blockInstance.clientId ); const blockName = parent ? parent.name : this.constructor.name; diff --git a/packages/schema-blocks/src/instructions/blocks/abstract/RichTextBase.ts b/packages/schema-blocks/src/instructions/blocks/abstract/RichTextBase.ts index 2028417a1..bb98e1b4c 100644 --- a/packages/schema-blocks/src/instructions/blocks/abstract/RichTextBase.ts +++ b/packages/schema-blocks/src/instructions/blocks/abstract/RichTextBase.ts @@ -1,10 +1,10 @@ -import { createElement } from "@wordpress/element"; +import { BlockConfiguration, BlockInstance } from "@wordpress/blocks"; import { RichText as WordPressRichText } from "@wordpress/block-editor"; - -import BlockInstruction from "../../../core/blocks/BlockInstruction"; +import { createElement } from "@wordpress/element"; +import { BlockLeaf, BlockInstruction } from "../../../core/blocks"; import { RenderSaveProps, RenderEditProps } from "../../../core/blocks/BlockDefinition"; -import BlockLeaf from "../../../core/blocks/BlockLeaf"; -import { BlockConfiguration } from "@wordpress/blocks"; +import { BlockPresence, BlockValidation, BlockValidationResult } from "../../../core/validation"; +import { getPresence } from "../../../functions/validators/getPresence"; export interface RichTextSaveProps extends WordPressRichText.ContentProps { "data-id": string; @@ -78,6 +78,28 @@ export default abstract class RichTextBase extends BlockInstruction { }; } + /** + * Checks if the instruction block is valid. + * + * @param blockInstance The attributes from the block. + * + * @returns {BlockValidationResult} The validation result. + */ + validate( blockInstance: BlockInstance ): BlockValidationResult { + const presence = getPresence( this.options ); + // Get the current editor content of this block from the store. + const content: string = blockInstance.attributes[ this.options.name ]; + if ( content && content.trim().length > 0 ) { + return BlockValidationResult.Valid( blockInstance, this.options.name, presence ); + } + + const validation = ( presence === BlockPresence.Required ) + ? BlockValidation.MissingRequiredAttribute + : BlockValidation.MissingRecommendedAttribute; + + return new BlockValidationResult( blockInstance.clientId, this.options.name, validation, presence ); + } + /** * Gets the base attributes of the rich text. * diff --git a/packages/schema-blocks/tests/core/blocks/BlockInstruction.test.ts b/packages/schema-blocks/tests/core/blocks/BlockInstruction.test.ts index 2578ae626..bea60ac59 100644 --- a/packages/schema-blocks/tests/core/blocks/BlockInstruction.test.ts +++ b/packages/schema-blocks/tests/core/blocks/BlockInstruction.test.ts @@ -1,6 +1,8 @@ import { BlockInstance } from "@wordpress/blocks"; import BlockInstruction from "../../../src/core/blocks/BlockInstruction"; -import { BlockValidation } from "../../../src/core/validation"; +import { InstructionOptions } from "../../../src/core/Instruction"; +import { BlockPresence, BlockValidation, BlockValidationResult } from "../../../src/core/validation"; + /** * Test class, to be able to test the non-abstract BlockInstruction methods. @@ -9,126 +11,21 @@ class TestBlockInstruction extends BlockInstruction { } describe( "The BlockInstruction class", () => { - describe( "validate method", () => { - it( "considers a core block with no required attributes Valid, if Gutenberg seems to think so.", () => { - const blockInstruction = new TestBlockInstruction( 11, null ); - - const blockInstance: BlockInstance = { - clientId: "clientid", - name: "core/whatever", - innerBlocks: [], - isValid: true, - attributes: {}, - }; - - const result = blockInstruction.validate( blockInstance ); - expect( result.name ).toEqual( "TestBlockInstruction" ); - expect( result.result ).toEqual( BlockValidation.Valid ); - expect( result.issues.length ).toEqual( 0 ); - } ); - - it( "considers a core block with no required attributes Invalid, if Gutenberg seems to think so.", () => { - const blockInstruction = new TestBlockInstruction( 11, null ); - - const blockInstance: BlockInstance = { - clientId: "clientid", - name: "core/whatever", - innerBlocks: [], - isValid: false, - attributes: {}, - }; - - const result = blockInstruction.validate( blockInstance ); - expect( result.name ).toEqual( "TestBlockInstruction" ); - expect( result.result ).toEqual( BlockValidation.Invalid ); - expect( result.issues.length ).toEqual( 1 ); - - const issue = result.issues[ 0 ]; - expect( issue.name ).toEqual( "TestBlockInstruction" ); - expect( issue.result ).toEqual( BlockValidation.Invalid ); - expect( issue.issues.length ).toEqual( 0 ); - } ); - - it( "considers a required attribute to be valid if it exists and is not empty", () => { - const blockInstruction = new TestBlockInstruction( 11, { name: "title", required: true } ); - - const blockInstance: BlockInstance = { - clientId: "clientid", - name: "blockName", - innerBlocks: [], - isValid: true, - attributes: { - title: "Hello, world!", - }, - }; - - const result = blockInstruction.validate( blockInstance ); - expect( result.name ).toEqual( "TestBlockInstruction" ); - expect( result.result ).toEqual( BlockValidation.Valid ); - expect( result.issues.length ).toEqual( 0 ); - } ); - - it( "considers a core block with a required attribute to be valid if the attribute exists and is not empty", () => { - const blockInstruction = new TestBlockInstruction( 11, { name: "title", required: true } ); - - const blockInstance: BlockInstance = { - clientId: "clientid", - name: "core/whatever", - innerBlocks: [], - isValid: true, - attributes: { - title: "Hello, world!", - }, - }; - - const result = blockInstruction.validate( blockInstance ); - expect( result.name ).toEqual( "TestBlockInstruction" ); - expect( result.result ).toEqual( BlockValidation.Valid ); - expect( result.issues.length ).toEqual( 0 ); - } ); - - it( "considers a required attribute to be invalid if it does not exist", () => { - const blockInstruction = new TestBlockInstruction( 11, { name: "title", required: true } ); - - const blockInstance: BlockInstance = { - clientId: "clientid", - name: "blockName", - innerBlocks: [], - isValid: true, - attributes: {}, - }; - - const result = blockInstruction.validate( blockInstance ); - expect( result.name ).toEqual( "TestBlockInstruction" ); - expect( result.result ).toEqual( BlockValidation.MissingRequiredAttribute ); - expect( result.issues.length ).toEqual( 1 ); - - const issue = result.issues[ 0 ]; - expect( issue.name ).toEqual( "TestBlockInstruction" ); - expect( issue.result ).toEqual( BlockValidation.MissingRequiredAttribute ); - } ); - - it( "considers a required attribute to be invalid if it is empty", () => { - const blockInstruction = new TestBlockInstruction( 11, { name: "title", required: true } ); - - const blockInstance: BlockInstance = { - clientId: "clientid", - name: "blockName", - innerBlocks: [], - isValid: true, - attributes: { - title: "", - }, - }; - - const result = blockInstruction.validate( blockInstance ); - expect( result.name ).toEqual( "TestBlockInstruction" ); - expect( result.result ).toEqual( BlockValidation.MissingRequiredAttribute ); - expect( result.issues.length ).toEqual( 1 ); - - const issue = result.issues[ 0 ]; - expect( issue.name ).toEqual( "TestBlockInstruction" ); - expect( issue.result ).toEqual( BlockValidation.MissingRequiredAttribute ); - } ); + it( "always returns an unknown validation result.", () => { + // Arrange. + const instruction = new TestBlockInstruction( 1, {} as unknown as InstructionOptions ); + const blockInstance: BlockInstance = { + clientId: "clientId", + name: "test/block", + innerBlocks: [], + isValid: true, + attributes: {}, + }; + + // Act. + const result = instruction.validate( blockInstance ); + + // Assert. + expect( result ).toEqual( new BlockValidationResult( "clientId", "TestBlockInstruction", BlockValidation.Unknown, BlockPresence.Unknown ) ); } ); } ); diff --git a/packages/schema-blocks/tests/functions/gutenberg/watchers/warningWatcher.test.ts b/packages/schema-blocks/tests/functions/gutenberg/watchers/warningWatcher.test.ts index 820f92362..a568f806b 100644 --- a/packages/schema-blocks/tests/functions/gutenberg/watchers/warningWatcher.test.ts +++ b/packages/schema-blocks/tests/functions/gutenberg/watchers/warningWatcher.test.ts @@ -44,6 +44,7 @@ jest.mock( "@wordpress/data", () => ( { dispatch: jest.fn( () => ( { insertBlock: jest.fn(), } ) ), + withSelect: jest.fn( () => jest.fn() ), } ) ); jest.mock( "@wordpress/components", () => { diff --git a/packages/schema-blocks/tests/functions/presenters/BlockSuggestions.test.ts b/packages/schema-blocks/tests/functions/presenters/BlockSuggestions.test.ts deleted file mode 100644 index 6801bb5dd..000000000 --- a/packages/schema-blocks/tests/functions/presenters/BlockSuggestions.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { BlockInstance, createBlock } from "@wordpress/blocks"; -import * as renderer from "react-test-renderer"; -import { mount } from "enzyme"; -import BlockSuggestionsPresenter from "../../../src/functions/presenters/BlockSuggestionsPresenter"; -import { insertBlock } from "../../../src/functions/innerBlocksHelper"; - -jest.mock( "@wordpress/blocks", () => { - return { - createBlock: jest.fn(), - }; -} ); - -jest.mock( "../../../src/functions/BlockHelper", () => { - return { - getBlockType: jest.fn( ( blockName: string ) => { - if ( blockName === "yoast/nonexisting" ) { - // eslint-disable-next-line no-undefined - return undefined; - } - - return { - title: "The required block", - }; - } ), - }; -} ); - -jest.mock( "../../../src/functions/innerBlocksHelper", () => { - return { - insertBlock: jest.fn(), - getInnerblocksByName: jest.fn( () => { - return [ - { - name: "yoast/added-to-content", - }, - ]; - } ), - }; -} ); - -describe( "The required blocks in the sidebar", () => { - it( "doesn't have the required block being registered as a block", () => { - const block = { innerBlocks: [] } as BlockInstance; - const requiredBlocks = [ "yoast/nonexisting" ]; - - const actual = BlockSuggestionsPresenter( { title: "Required blocks", block, suggestions: requiredBlocks } ); - - expect( actual ).toBe( null ); - } ); - - it( "renders the required block as an added one", () => { - const block = { innerBlocks: [] } as BlockInstance; - const requiredBlocks = [ "yoast/added-to-content" ]; - - const tree = renderer - .create( BlockSuggestionsPresenter( { title: "Required blocks", block, suggestions: requiredBlocks } ) ) - .toJSON(); - - expect( tree ).toMatchSnapshot(); - } ); - - it( "renders the required block as a non-added one", () => { - const block = { innerBlocks: [] } as BlockInstance; - const requiredBlocks = [ "yoast/non-added-to-content" ]; - - const tree = renderer - .create( BlockSuggestionsPresenter( { title: "Required blocks", block, suggestions: requiredBlocks } ) ) - .toJSON(); - - expect( tree ).toMatchSnapshot(); - } ); - - it( "should call the function to add the block when the button is clicked.", () => { - const block = { innerBlocks: [], clientId: "1" } as BlockInstance; - const requiredBlocks = [ "yoast/non-added-to-content" ]; - - const tree = mount( BlockSuggestionsPresenter( { title: "Required blocks", block, suggestions: requiredBlocks } ) ); - - const addButton = tree.find( "button" ).first(); - - addButton.simulate( "click" ); - - expect( createBlock ).toHaveBeenCalled(); - expect( insertBlock ).toHaveBeenCalled(); - } ); -} ); diff --git a/packages/schema-blocks/tests/functions/presenters/BlockSuggestionsPresenter.test.ts b/packages/schema-blocks/tests/functions/presenters/BlockSuggestionsPresenter.test.ts new file mode 100644 index 000000000..a6a3201f6 --- /dev/null +++ b/packages/schema-blocks/tests/functions/presenters/BlockSuggestionsPresenter.test.ts @@ -0,0 +1,199 @@ +import { mount } from "enzyme"; +import * as renderer from "react-test-renderer"; +import { createBlock } from "@wordpress/blocks"; +import { BlockValidation, BlockValidationResult, BlockPresence } from "../../../src/core/validation"; +import { PureBlockSuggestionsPresenter, SuggestionDetails, SuggestionsProps } from "../../../src/functions/presenters/BlockSuggestionsPresenter"; +import { insertBlock } from "../../../src/functions/innerBlocksHelper"; + +jest.mock( "@wordpress/blocks", () => { + return { + createBlock: jest.fn(), + }; +} ); + +jest.mock( "../../../src/functions/validators", () => { + return { + getValidationResults: jest.fn( () => { + return new BlockValidationResult( "1", "yoast/valid-block", -1, BlockPresence.Required, "Is not that present" ); + } ), + getValidationResultForClientId: jest.fn( () => { + return [ + new BlockValidationResult( "123", "yoast/added-to-content-valid", 1, BlockPresence.Required, "Is present" ), + ]; + } ), + }; +} ); + +jest.mock( "../../../src/functions/BlockHelper", () => { + return { + getBlockType: jest.fn( ( blockName: string ) => { + if ( blockName === "yoast/nonexisting" ) { + // eslint-disable-next-line no-undefined + return undefined; + } + + return { + heading: "The required block", + }; + } ), + }; +} ); + +jest.mock( "../../../src/functions/innerBlocksHelper", () => { + return { + insertBlock: jest.fn(), + getInnerblocksByName: jest.fn( () => { + return [ + { + name: "yoast/added-to-content", + clientId: "existingBlockClientId", + }, + ]; + } ), + }; +} ); + +/** + * Creates a mockery of a SuggestionDetails object + * @param title The Validated Block's title. + * @param validation The validation result. + * @returns Most of a SuggestionDetails object. + */ +function createSuggestion( title: string, validation: BlockValidationResult ): SuggestionDetails { + const suggestion = ( validation as unknown as SuggestionDetails ); + suggestion.title = title; + return suggestion; +} + +describe( "The BlockSuggestionsPresenter class ", () => { + it( "displays an [ Add ] link for missing required blocks", () => { + // Arrange. + const suggestion: SuggestionsProps = { + heading: "Heading for Required Blocks", + parentClientId: "parentClientId", + suggestions: [ + createSuggestion( + "This is a missing block", + new BlockValidationResult( null, "yoast/requiredBlock", BlockValidation.MissingRequiredBlock, BlockPresence.Required, null ), + ), + ], + blockNames: [ "yoast/requiredBlock" ], + }; + + // Act. + const tree = renderer + .create( PureBlockSuggestionsPresenter( suggestion ) ) + .toJSON(); + + // Assert. + expect( tree ).toMatchSnapshot(); + } ); + + it( "displays only the block title for blocks that aren't completed", () => { + // Arrange. + const suggestion: SuggestionsProps = { + heading: "Heading for Required Blocks", + parentClientId: "parentClientId", + suggestions: [ + createSuggestion( + "This is an invalid required block without checkmark or add link", + new BlockValidationResult( null, "yoast/requiredBlock", BlockValidation.MissingRequiredAttribute, BlockPresence.Required, null ), + ), + ], + blockNames: [ "yoast/requiredBlock" ], + }; + + // Act. + const tree = renderer + .create( PureBlockSuggestionsPresenter( suggestion ) ) + .toJSON(); + + // Assert. + expect( tree ).toMatchSnapshot(); + } ); + + it( "displays a checkmark for valid blocks", () => { + // Arrange. + const suggestion: SuggestionsProps = { + heading: "Heading for Required Blocks", + parentClientId: "parentClientId", + suggestions: [ + createSuggestion( + "This is a valid required block with checkmark without add link", + new BlockValidationResult( null, "yoast/requiredBlock", BlockValidation.Valid, BlockPresence.Required, null ), + ), + ], + blockNames: [ "yoast/requiredBlock" ], + }; + + // Act. + const tree = renderer + .create( PureBlockSuggestionsPresenter( suggestion ) ) + .toJSON(); + + // Assert. + expect( tree ).toMatchSnapshot(); + } ); + + it( "displays no suggestions if no suggestions are provided", () => { + // Arrange. + // eslint-disable-next-line max-len + const suggestions: SuggestionsProps = { + heading: "Recommended blocks", + parentClientId: "parentClientId", + suggestions: [], + blockNames: [ "yoast/recommendedBlock" ], + }; + + // Act. + const tree = renderer + .create( PureBlockSuggestionsPresenter( suggestions ) ) + .toJSON(); + + // Assert. + expect( tree ).toBeNull(); + } ); + + it( "displays no suggestions if no blockNames are provided", () => { + // Arrange. + // eslint-disable-next-line max-len + const validation = new BlockValidationResult( null, "yoast/recommendedBlock", BlockValidation.MissingRecommendedBlock, BlockPresence.Required, null ); + const suggestions: SuggestionDetails[] = + [ + createSuggestion( + "yoast/recommendedBlock", + validation, + ), + ]; + const parentClientId = "parentClientId"; + + // Act. + const tree = renderer + .create( PureBlockSuggestionsPresenter( { heading: "Recommended blocks", parentClientId, suggestions } as SuggestionsProps ) ) + .toJSON(); + + // Assert. + expect( tree ).toBeNull(); + } ); + + it( "should add the block when the [ Add ] button is clicked.", () => { + const suggestionsProps = { + heading: "Required blocks", + parentClientId: "parentClientId", + suggestions: [ + createSuggestion( "yoast/not-added-to-content", + BlockValidationResult.MissingBlock( "yoast/not-added-to-content", BlockPresence.Required ) ), + ], + blockNames: [ "yoast/not-added-to-content" ], + } as SuggestionsProps; + + const tree = mount( PureBlockSuggestionsPresenter( suggestionsProps ) ); + + const addButton = tree.find( "button" ).first(); + + addButton.simulate( "click" ); + + expect( createBlock ).toHaveBeenCalled(); + expect( insertBlock ).toHaveBeenCalled(); + } ); +} ); diff --git a/packages/schema-blocks/tests/functions/presenters/SidebarWarningPresenter.test.ts b/packages/schema-blocks/tests/functions/presenters/SidebarWarningPresenter.test.ts index b9872dee3..002edcd0f 100644 --- a/packages/schema-blocks/tests/functions/presenters/SidebarWarningPresenter.test.ts +++ b/packages/schema-blocks/tests/functions/presenters/SidebarWarningPresenter.test.ts @@ -1,22 +1,8 @@ import { BlockValidation, BlockValidationResult } from "../../../src/core/validation"; -import getWarnings, { createAnalysisMessages } from "../../../src/functions/presenters/SidebarWarningPresenter"; +import { createAnalysisMessages } from "../../../src/functions/presenters/SidebarWarningPresenter"; import { BlockPresence } from "../../../src/core/validation/BlockValidationResult"; import { BlockInstance } from "@wordpress/blocks"; -const validations: Record = {}; - -jest.mock( "@wordpress/data", () => { - return { - select: jest.fn( () => { - return { - getSchemaBlocksValidationResults: jest.fn( () => { - return validations; - } ), - }; - } ), - }; -} ); - jest.mock( "../../../src/functions/BlockHelper", () => { return { getHumanReadableBlockName: jest.fn( name => name ), @@ -66,7 +52,9 @@ describe( "The SidebarWarningPresenter ", () => { it( "creates warning messages for missing required blocks, with a footer message.", () => { const testcase = new BlockValidationResult( "1", "mijnblock", BlockValidation.Invalid, BlockPresence.Required ); - testcase.issues.push( BlockValidationResult.MissingBlock( "missingblock", BlockPresence.Required ) ); + const missing = BlockValidationResult.MissingBlock( "missingblock", BlockPresence.Required ); + missing.message = "The `missingblock` block is required but missing."; + testcase.issues.push( missing ); const result = createAnalysisMessages( testcase ); @@ -83,8 +71,7 @@ describe( "The SidebarWarningPresenter ", () => { ); } ); - it( "creates a warning for missing recommended blocks, but when no required blocks are missing, " + - "the conclusion should still be green.", () => { + it( "creates a warning for missing recommended blocks, but when no blocks are required, but the conclusion should still be green.", () => { const testcase = new BlockValidationResult( "1", "mijnblock", BlockValidation.MissingRecommendedBlock, BlockPresence.Recommended ); testcase.issues.push( BlockValidationResult.MissingBlock( "missing recommended block", BlockPresence.Recommended ), @@ -110,8 +97,7 @@ describe( "The SidebarWarningPresenter ", () => { } ); } ); - it( "creates a warning for missing recommended blocks, but when all required blocks are valid, " + - "the conclusion should still be green.", () => { + it( "creates a warning for missing recommended blocks, but when all required blocks are valid, the conclusion should still be green.", () => { const testcase = new BlockValidationResult( "1", "mijnblock", BlockValidation.MissingRecommendedBlock, BlockPresence.Recommended ); testcase.issues.push( BlockValidationResult.MissingBlock( "missing recommended block", BlockPresence.Recommended ), @@ -134,11 +120,11 @@ describe( "The SidebarWarningPresenter ", () => { } ); } ); - describe( "The getWarnings method ", () => { + describe( "The createAnalysisMessages method ", () => { it( "creates a compliment for required valid blocks.", () => { - validations[ "1" ] = new BlockValidationResult( "1", "myBlock", BlockValidation.Valid, BlockPresence.Required ); + const validation = new BlockValidationResult( "1", "myBlock", BlockValidation.Valid, BlockPresence.Required ); - const result = getWarnings( "1" ); + const result = createAnalysisMessages( validation ); expect( result ).toEqual( [ { text: "Good job! All required blocks have been completed.", @@ -152,9 +138,8 @@ describe( "The SidebarWarningPresenter ", () => { testcase.issues.push( new BlockValidationResult( "2", "innerblock1", BlockValidation.Skipped, BlockPresence.Required ) ); testcase.issues.push( new BlockValidationResult( "3", "anotherinnerblock", BlockValidation.TooMany, BlockPresence.Required ) ); testcase.issues.push( new BlockValidationResult( "4", "anotherinnerblock", BlockValidation.Unknown, BlockPresence.Required ) ); - validations[ "1" ] = testcase; - const result = getWarnings( "1" ); + const result = createAnalysisMessages( testcase ); expect( result ).toEqual( [ { text: "Good job! All required blocks have been completed.", @@ -165,9 +150,8 @@ describe( "The SidebarWarningPresenter ", () => { it( "creates a warning for a required block with validation problems.", () => { const testcase = new BlockValidationResult( "1", "myBlock", BlockValidation.Invalid, BlockPresence.Required ); testcase.issues.push( BlockValidationResult.MissingBlock( "innerblock1", BlockPresence.Required ) ); - validations[ "1" ] = testcase; - const result = getWarnings( "1" ); + const result = createAnalysisMessages( testcase ); expect( result.length ).toEqual( 2 ); expect( result[ 0 ] ).toEqual( { @@ -179,11 +163,11 @@ describe( "The SidebarWarningPresenter ", () => { color: "red", } ); } ); - } ); - it( "creates no output when the validation results cannot be retrieved.", () => { - const result = getWarnings( "123" ); + it( "creates no output when the validation results cannot be retrieved.", () => { + const result = createAnalysisMessages( null ); - expect( result ).toBeNull(); + expect( result ).toEqual( [] ); + } ); } ); } ); diff --git a/packages/schema-blocks/tests/functions/presenters/__snapshots__/BlockSuggestions.test.ts.snap b/packages/schema-blocks/tests/functions/presenters/__snapshots__/BlockSuggestions.test.ts.snap deleted file mode 100644 index 118142d75..000000000 --- a/packages/schema-blocks/tests/functions/presenters/__snapshots__/BlockSuggestions.test.ts.snap +++ /dev/null @@ -1,64 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`The required blocks in the sidebar renders the required block as a non-added one 1`] = ` -Array [ -
    - Required blocks -
    , -
      -
    • - The required block - -
    • -
    , -] -`; - -exports[`The required blocks in the sidebar renders the required block as an added one 1`] = ` -Array [ -
    - Required blocks -
    , -
      -
    • - The required block - - - - - -
    • -
    , -] -`; diff --git a/packages/schema-blocks/tests/functions/presenters/__snapshots__/BlockSuggestionsPresenter.test.ts.snap b/packages/schema-blocks/tests/functions/presenters/__snapshots__/BlockSuggestionsPresenter.test.ts.snap new file mode 100644 index 000000000..67bfc0e87 --- /dev/null +++ b/packages/schema-blocks/tests/functions/presenters/__snapshots__/BlockSuggestionsPresenter.test.ts.snap @@ -0,0 +1,89 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`The BlockSuggestionsPresenter class displays a checkmark for valid blocks 1`] = ` +
    +
    + Heading for Required Blocks +
    +
      +
    • + This is a valid required block with checkmark without add link + + + + + +
    • +
    +
    +`; + +exports[`The BlockSuggestionsPresenter class displays an [ Add ] link for missing required blocks 1`] = ` +
    +
    + Heading for Required Blocks +
    +
      +
    • + This is a missing block + +
    • +
    +
    +`; + +exports[`The BlockSuggestionsPresenter class displays only the block title for blocks that aren't completed 1`] = ` +
    +
    + Heading for Required Blocks +
    +
      +
    • + This is an invalid required block without checkmark or add link +
    • +
    +
    +`; diff --git a/packages/schema-blocks/tests/functions/validators/defaultValidation.test.ts b/packages/schema-blocks/tests/functions/validators/defaultValidation.test.ts new file mode 100644 index 000000000..4f1da3995 --- /dev/null +++ b/packages/schema-blocks/tests/functions/validators/defaultValidation.test.ts @@ -0,0 +1,139 @@ +import { BlockInstance } from "@wordpress/blocks"; +import { BlockValidation, BlockValidationResult } from "../../../src/core/validation"; +import { BlockInstruction } from "../../../src/core/blocks"; +import { defaultValidate } from "../../../src/functions/validators/defaultValidate"; + +/** + * Test class, to be able to test the non-abstract BlockInstruction methods. + */ +class TestBlockInstruction extends BlockInstruction { + // eslint-disable-next-line require-jsdoc + validate( blockInstance: BlockInstance ): BlockValidationResult { + return defaultValidate( blockInstance, this ); + } +} + +describe( "The BlockInstruction class", () => { + describe( "validate method", () => { + it( "considers a core block with no required attributes Valid, if Gutenberg seems to think so.", () => { + const blockInstruction = new TestBlockInstruction( 11, null ); + + const blockInstance: BlockInstance = { + clientId: "clientid", + name: "core/whatever", + innerBlocks: [], + isValid: true, + attributes: {}, + }; + + const result = blockInstruction.validate( blockInstance ); + expect( result.name ).toEqual( "TestBlockInstruction" ); + expect( result.result ).toEqual( BlockValidation.Valid ); + expect( result.issues.length ).toEqual( 0 ); + } ); + + it( "considers a core block with no required attributes Invalid, if Gutenberg seems to think so.", () => { + const blockInstruction = new TestBlockInstruction( 11, null ); + + const blockInstance: BlockInstance = { + clientId: "clientid", + name: "core/whatever", + innerBlocks: [], + isValid: false, + attributes: {}, + }; + + const result = blockInstruction.validate( blockInstance ); + expect( result.name ).toEqual( "TestBlockInstruction" ); + expect( result.result ).toEqual( BlockValidation.Invalid ); + expect( result.issues.length ).toEqual( 1 ); + + const issue = result.issues[ 0 ]; + expect( issue.name ).toEqual( "TestBlockInstruction" ); + expect( issue.result ).toEqual( BlockValidation.Invalid ); + expect( issue.issues.length ).toEqual( 0 ); + } ); + + it( "considers a required attribute to be valid if it exists and is not empty", () => { + const blockInstruction = new TestBlockInstruction( 11, { name: "title", required: true } ); + + const blockInstance: BlockInstance = { + clientId: "clientid", + name: "blockName", + innerBlocks: [], + isValid: true, + attributes: { + title: "Hello, world!", + }, + }; + + const result = blockInstruction.validate( blockInstance ); + expect( result.name ).toEqual( "TestBlockInstruction" ); + expect( result.result ).toEqual( BlockValidation.Valid ); + expect( result.issues.length ).toEqual( 0 ); + } ); + + it( "considers a core block with a required attribute to be valid if the attribute exists and is not empty", () => { + const blockInstruction = new TestBlockInstruction( 11, { name: "title", required: true } ); + + const blockInstance: BlockInstance = { + clientId: "clientid", + name: "core/whatever", + innerBlocks: [], + isValid: true, + attributes: { + title: "Hello, world!", + }, + }; + + const result = blockInstruction.validate( blockInstance ); + expect( result.name ).toEqual( "TestBlockInstruction" ); + expect( result.result ).toEqual( BlockValidation.Valid ); + expect( result.issues.length ).toEqual( 0 ); + } ); + + it( "considers a required attribute to be invalid if it does not exist", () => { + const blockInstruction = new TestBlockInstruction( 11, { name: "title", required: true } ); + + const blockInstance: BlockInstance = { + clientId: "clientid", + name: "blockName", + innerBlocks: [], + isValid: true, + attributes: {}, + }; + + const result = blockInstruction.validate( blockInstance ); + expect( result.name ).toEqual( "TestBlockInstruction" ); + expect( result.result ).toEqual( BlockValidation.MissingRequiredAttribute ); + expect( result.issues.length ).toEqual( 1 ); + + const issue = result.issues[ 0 ]; + expect( issue.name ).toEqual( "TestBlockInstruction" ); + expect( issue.result ).toEqual( BlockValidation.MissingRequiredAttribute ); + } ); + + it( "considers a required attribute to be invalid if it is empty", () => { + const blockInstruction = new TestBlockInstruction( 11, { name: "title", required: true } ); + + const blockInstance: BlockInstance = { + clientId: "clientid", + name: "blockName", + innerBlocks: [], + isValid: true, + attributes: { + title: "", + }, + }; + + const result = blockInstruction.validate( blockInstance ); + expect( result.name ).toEqual( "TestBlockInstruction" ); + expect( result.result ).toEqual( BlockValidation.MissingRequiredAttribute ); + expect( result.issues.length ).toEqual( 1 ); + + const issue = result.issues[ 0 ]; + expect( issue.name ).toEqual( "TestBlockInstruction" ); + expect( issue.result ).toEqual( BlockValidation.MissingRequiredAttribute ); + } ); + } ); +} ); diff --git a/packages/schema-blocks/tests/functions/validators/getValidationResultForClientId.test.ts b/packages/schema-blocks/tests/functions/validators/getValidationResultForClientId.test.ts new file mode 100644 index 000000000..f925556c1 --- /dev/null +++ b/packages/schema-blocks/tests/functions/validators/getValidationResultForClientId.test.ts @@ -0,0 +1,100 @@ +import "../../matchMedia.mock"; +import { BlockInstance } from "@wordpress/blocks"; +import { select } from "@wordpress/data"; +import { BlockValidation, BlockValidationResult } from "../../../src/core/validation"; +import { BlockPresence } from "../../../src/core/validation/BlockValidationResult"; +import { getValidationResultForClientId } from "../../../src/functions/validators"; + +let input: BlockValidationResult[] = []; + +const defaultTestInput = [ + BlockValidationResult.Valid( { clientId: "validClientId" } as unknown as BlockInstance, "yoast/valid-block", BlockPresence.Required ), + BlockValidationResult.MissingBlock( "yoast/missing-block", BlockPresence.Required ), +]; +const nestedValidationResult = { + clientId: "BlockWithNestedIssues", + result: BlockValidation.Invalid, + issues: [ + { + clientId: "NestedBlockwithIssues", + result: BlockValidation.MissingRequiredVariation, + issues: [], + } as BlockValidationResult, + ], +} as BlockValidationResult; + + +jest.mock( "@wordpress/data", () => { + return { + select: jest.fn( () => { + return { + getSchemaBlocksValidationResults: jest.fn(), + }; + } ), + dispatch: jest.fn( () => null ), + }; +} ); + +describe( "The getValidationResultForClientId function ", () => { + it( "returns null if no validation is found for the given clientId", () => { + // Arrange. + input = defaultTestInput; + + // Act. + const result = getValidationResultForClientId( "clientId does not occur in list", input ); + + // Assert. + expect( result ).toBeNull(); + } ); + + it( "retrieves the validation results from the store if none are passed as argument.", () => { + // Arrange. + input = []; + + // Act. + const result = getValidationResultForClientId( "clientId does not occur in list", null ); + + // Assert. + expect( select ).toBeCalled(); + expect( result ).toBeNull(); + } ); + + it( "returns the validationResult for the clientId if it is at root level", () => { + // Arrange. + input = defaultTestInput; + input.push( nestedValidationResult ); + + // Act. + const validation = getValidationResultForClientId( "BlockWithNestedIssues", input ); + + // Assert. + expect( validation ).not.toBeNull(); + expect( validation.result ).toBe( BlockValidation.Invalid ); + } ); + + it( "returns the validationResult for the clientId if it is nested 1 level deep", () => { + // Arrange. + input = defaultTestInput; + input.push( nestedValidationResult ); + + // Act. + const validation = getValidationResultForClientId( "NestedBlockwithIssues", input ); + + // Assert. + expect( validation ).not.toBeNull(); + expect( validation.result ).toBe( BlockValidation.MissingRequiredVariation ); + } ); + + it( "returns the validationResult for the clientId if it is nested more than 1 level deep", () => { + // Arrange. + input = defaultTestInput; + input[ 0 ].issues.push( nestedValidationResult ); + + // Act. + const validation = getValidationResultForClientId( "NestedBlockwithIssues", input ); + + // Assert. + expect( validation ).not.toBeNull(); + expect( validation.result ).toBe( BlockValidation.MissingRequiredVariation ); + } ); +} ); diff --git a/packages/schema-blocks/tests/instructions/blocks/Date.test.ts b/packages/schema-blocks/tests/instructions/blocks/Date.test.ts index e069cfe8d..cdcde844e 100644 --- a/packages/schema-blocks/tests/instructions/blocks/Date.test.ts +++ b/packages/schema-blocks/tests/instructions/blocks/Date.test.ts @@ -10,6 +10,7 @@ jest.mock( "@wordpress/date", () => ( { __experimentalGetSettings: jest.fn(), dateI18n: jest.fn(), } ) ); +jest.mock( "../../../src/instructions/blocks/InnerBlocks", () => ( {} ) ); const mockedGetSettings = mocked( __experimentalGetSettings, false ); const mockedDateI18n = mocked( dateI18n, false ); diff --git a/packages/schema-blocks/tests/instructions/blocks/Select.test.ts b/packages/schema-blocks/tests/instructions/blocks/Select.test.ts index 0b5aae7e6..ae925a606 100644 --- a/packages/schema-blocks/tests/instructions/blocks/Select.test.ts +++ b/packages/schema-blocks/tests/instructions/blocks/Select.test.ts @@ -1,7 +1,7 @@ import * as renderer from "react-test-renderer"; import { ReactElement } from "@wordpress/element"; -import { BlockConfiguration, BlockInstance } from "@wordpress/blocks"; +import { BlockConfiguration } from "@wordpress/blocks"; import Select from "../../../src/instructions/blocks/Select"; import { RenderSaveProps } from "../../../src/core/blocks/BlockDefinition"; @@ -79,66 +79,4 @@ describe( "The Select instruction", () => { expect( tree ).toMatchSnapshot(); } ); } ); - - describe( "the valid method", () => { - it( "returns true when the instruction is required and the value exists and is filled in.", () => { - const selectInstruction = new Select( 123, options ); - - const blockInstance: BlockInstance = { - name: "select-instruction", - clientId: "abcd-1234", - isValid: true, - innerBlocks: [], - attributes: { - cuisine: "tanzanian", - }, - }; - - expect( selectInstruction.valid( blockInstance ) ).toEqual( true ); - } ); - - it( "returns true when the instruction is not required and the value exists.", () => { - const selectInstruction = new Select( 123, options ); - - const blockInstance: BlockInstance = { - name: "select-instruction", - clientId: "abcd-1234", - isValid: true, - innerBlocks: [], - attributes: { - cuisine: "", - }, - }; - - expect( selectInstruction.valid( blockInstance ) ).toEqual( true ); - } ); - - it( "returns false when the instruction is not required and the value does not exist.", () => { - const selectInstruction = new Select( 123, options ); - - const blockInstance: BlockInstance = { - name: "select-instruction", - clientId: "abcd-1234", - isValid: true, - innerBlocks: [], - attributes: {}, - }; - - expect( selectInstruction.valid( blockInstance ) ).toEqual( false ); - } ); - - it( "returns false when the instruction is required and the value does not exist.", () => { - const selectInstruction = new Select( 123, options ); - - const blockInstance: BlockInstance = { - name: "select-instruction", - clientId: "abcd-1234", - isValid: true, - innerBlocks: [], - attributes: {}, - }; - - expect( selectInstruction.valid( blockInstance ) ).toEqual( false ); - } ); - } ); } );