├── .babelrc.js ├── src ├── settings.js ├── plugins │ └── relationships │ │ ├── index.js │ │ ├── containers │ │ └── Relationships.js │ │ └── components │ │ ├── RelationshipItem.js │ │ ├── Pagination.js │ │ └── Relationships.js ├── blocks │ └── reusable-block │ │ ├── containers │ │ └── Edit.js │ │ ├── index.js │ │ └── components │ │ ├── ListItem.js │ │ ├── Filter.js │ │ ├── List.js │ │ └── Edit.js ├── index.js ├── utils │ └── fetch.js └── styles.scss ├── .gitignore ├── .config ├── webpack.config.prod.js └── webpack.config.dev.js ├── .github ├── ISSUE_TEMPLATE │ ├── enhancement-proposed.md │ ├── enhancement-requested.md │ └── bug_report.md ├── PULL_REQUEST_TEMPLATE │ └── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── release.yml │ └── main.yml ├── inc ├── rest-api │ ├── namespace.php │ ├── search │ │ └── class-rest-endpoint.php │ └── relationships │ │ └── class-rest-endpoint.php ├── categories.php ├── namespace.php └── connections.php ├── .editorconfig ├── phpunit.xml.dist ├── tests └── unit │ ├── TestCase.php │ ├── CategoriesTest.php │ ├── rest-api │ ├── search │ │ └── RESTEndpointTest.php │ └── relationships │ │ └── RESTEndpointTest.php │ ├── NamespaceTest.php │ └── ConnectionsTest.php ├── plugin.php ├── phpcs.xml.dist ├── package.json ├── composer.json ├── .eslintrc.js └── README.md /.babelrc.js: -------------------------------------------------------------------------------- 1 | module.exports = require( '@humanmade/webpack-helpers/babel-preset' ); 2 | -------------------------------------------------------------------------------- /src/settings.js: -------------------------------------------------------------------------------- 1 | const { altisReusableBlocksSettings } = window; 2 | 3 | export default altisReusableBlocksSettings; 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Composer 2 | /vendor/ 3 | 4 | # Node.js 5 | /node_modules/ 6 | 7 | # Webpack 8 | /build/ 9 | 10 | 11 | # Built and generated files 12 | /deploy/ 13 | 14 | 15 | # Git 16 | !.gitkeep 17 | -------------------------------------------------------------------------------- /src/plugins/relationships/index.js: -------------------------------------------------------------------------------- 1 | import render from './containers/Relationships'; 2 | 3 | export const name = 'altis-reusable-block-relationships'; 4 | 5 | export const settings = { 6 | icon: 'admin-links', 7 | render, 8 | }; 9 | -------------------------------------------------------------------------------- /.config/webpack.config.prod.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file defines the production build configuration 3 | */ 4 | const { helpers, externals, presets, plugins } = require( '@humanmade/webpack-helpers' ); 5 | const { filePath } = helpers; 6 | 7 | module.exports = presets.production( { 8 | externals, 9 | entry: { 10 | index: filePath( 'src/index.js' ), 11 | }, 12 | } ); 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/enhancement-proposed.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Enhancement proposed 3 | about: This is an enhancement proposal 4 | title: '' 5 | labels: Enhancement proposed 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Description** 11 | Describe the feature 12 | 13 | **Scenario** 14 | If possible provide examples user stories demonstrating the feature 15 | 16 | **Any related issues** 17 | Please provide a link to any other open issues that this relates to. 18 | -------------------------------------------------------------------------------- /inc/rest-api/namespace.php: -------------------------------------------------------------------------------- 1 | 8 | presets.development( { 9 | name: 'main', 10 | devServer: { 11 | port, 12 | }, 13 | externals, 14 | entry: { 15 | index: filePath( 'src/index.js' ), 16 | }, 17 | } ), 18 | ); 19 | -------------------------------------------------------------------------------- /src/plugins/relationships/containers/Relationships.js: -------------------------------------------------------------------------------- 1 | import Relationships from '../components/Relationships'; 2 | 3 | import { compose } from '@wordpress/compose'; 4 | import { withSelect } from '@wordpress/data'; 5 | 6 | export const mapSelectToProps = ( select ) => { 7 | const { getCurrentPostId } = select( 'core/editor' ); 8 | 9 | return { 10 | currentPostId: getCurrentPostId(), 11 | }; 12 | }; 13 | 14 | export default compose( [ 15 | withSelect( mapSelectToProps ), 16 | ] )( Relationships ); 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/enhancement-requested.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Enhancement requested 3 | about: This is an enhancement. 4 | title: '' 5 | labels: Enhancement requested 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Description** 11 | Describe the feature 12 | 13 | **Scenario** 14 | If possible provide examples user stories demonstrating the feature 15 | 16 | > e.g., As a ______, I want to ________, So that I can ________. 17 | 18 | This will allow us to understand what the enhancement is, for whom it is intended and why. 19 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # This file is for unifying the coding style for different editors and IDEs 2 | # editorconfig.org 3 | 4 | # WordPress Coding Standards 5 | # http://make.wordpress.org/core/handbook/coding-standards/ 6 | 7 | root = true 8 | 9 | [*] 10 | charset = utf-8 11 | end_of_line = lf 12 | insert_final_newline = true 13 | trim_trailing_whitespace = true 14 | indent_style = tab 15 | indent_size = 4 16 | 17 | [*.{json,yml,feature,config}] 18 | indent_style = space 19 | indent_size = 2 20 | 21 | [{composer.json, *.yaml}] 22 | indent_style = space 23 | indent_size = 4 24 | -------------------------------------------------------------------------------- /src/blocks/reusable-block/containers/Edit.js: -------------------------------------------------------------------------------- 1 | import { compose } from '@wordpress/compose'; 2 | import { withSelect } from '@wordpress/data'; 3 | 4 | import ReusableBlockEdit from '../components/Edit'; 5 | 6 | export const mapSelectToProps = ( select ) => { 7 | const { getEntityRecords } = select( 'core' ); 8 | 9 | const categoriesList = getEntityRecords( 'taxonomy', 'wp_block_category', { per_page: 100 } ); 10 | 11 | return { 12 | categoriesList, 13 | }; 14 | }; 15 | 16 | export default compose( [ 17 | withSelect( mapSelectToProps ), 18 | ] )( ReusableBlockEdit ); 19 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | tests/unit 11 | 12 | 13 | 14 | 15 | inc 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/blocks/reusable-block/index.js: -------------------------------------------------------------------------------- 1 | import { __ } from '@wordpress/i18n'; 2 | 3 | import edit from './containers/Edit'; 4 | 5 | export const name = 'altis/reusable-block'; 6 | 7 | export const options = { 8 | category: 'common', 9 | description: __( 10 | 'Create content, and save it for you and other contributors to reuse across your site.', 11 | 'altis-reusable-blocks' 12 | ), 13 | edit, 14 | icon: 'controls-repeat', 15 | save: () => null, 16 | title: __( 'Reusable Block', 'altis-reusable-blocks' ), 17 | supports: { 18 | customClassName: false, 19 | html: false, 20 | reusable: false, 21 | }, 22 | }; 23 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { registerBlockType } from '@wordpress/blocks'; 2 | import { registerPlugin } from '@wordpress/plugins'; 3 | 4 | import * as RelationshipsPlugin from './plugins/relationships'; 5 | 6 | import * as ReusableBlock from './blocks/reusable-block'; 7 | 8 | import pluginSettings from './settings'; 9 | 10 | import './styles.scss'; 11 | 12 | const { context } = pluginSettings; 13 | 14 | const { postType } = context; 15 | 16 | if ( postType === 'wp_block' ) { 17 | registerPlugin( RelationshipsPlugin.name, RelationshipsPlugin.settings ); 18 | } 19 | 20 | registerBlockType( ReusableBlock.name, ReusableBlock.options ); 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a bug report to help us improve. 4 | title: '' 5 | labels: Bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Description** 11 | 12 | 13 | **Steps to Reproduce** 14 | 1. Go to '...' 15 | 2. Click on '....' 16 | 3. Scroll down to '....' 17 | 4. See error 18 | 19 | **Expected Behavior** 20 | 21 | 22 | **Screenshots & Video** 23 | 24 | 25 | **Desktop (please complete the following information):** 26 | - Browser and Version 27 | -------------------------------------------------------------------------------- /src/utils/fetch.js: -------------------------------------------------------------------------------- 1 | import _zipObject from 'lodash/zipObject'; 2 | 3 | import apiFetch from '@wordpress/api-fetch'; 4 | 5 | /** 6 | * Fetch JSON. 7 | * 8 | * Helper function to return parsed JSON and also the response headers. 9 | * 10 | * @param {Object} args - Array of arguments to pass to apiFetch. 11 | * @param {Object[]} headerKeys - Array of headers to include. 12 | */ 13 | export const fetchJson = ( args, headerKeys = [ 'x-wp-totalpages' ] ) => { 14 | return apiFetch( 15 | { 16 | ...args, 17 | parse: false, 18 | } 19 | ).then( ( response ) => { 20 | return Promise.all( [ 21 | response.json ? response.json() : [], 22 | _zipObject( headerKeys, headerKeys.map( ( key ) => response.headers.get( key ) ) ), 23 | ] ); 24 | } ); 25 | }; 26 | -------------------------------------------------------------------------------- /tests/unit/TestCase.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | . 5 | 6 | build/* 7 | node_modules/* 8 | src/* 9 | vendor/* 10 | wp-content/* 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Description 4 | _Please ensure `Fixes #{issue_number} - {Description}` is used_ 5 | 6 | ## How Has This Been Tested? 7 | _Does this meet all scenarios *if* outline within its related ticket, if the ticket doesnt have a scenario why not add one reflecting your own testing?_ 8 | 9 | ## Types of changes 10 | 11 | - [ ] Bug fix (non-breaking change which fixes an issue). 12 | - [ ] New feature (non-breaking change which adds functionality). 13 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected). 14 | 15 | ## Checklist: 16 | - [ ] My code follows the code style of the project. 17 | - [ ] I have updated the documentation (if applicable). 18 | - [ ] I have updated the documentation accordingly. 19 | - [ ] I have included the relevant methods of testing. 20 | -------------------------------------------------------------------------------- /src/plugins/relationships/components/RelationshipItem.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | 3 | import { PanelRow } from '@wordpress/components'; 4 | import { __, sprintf } from '@wordpress/i18n'; 5 | 6 | import settings from '../../../settings'; 7 | 8 | const { editPostUrl } = settings; 9 | 10 | const RelationshipItem = ( { id, status, title } ) => { 11 | const itemTitle = title.rendered || __( '(No Title)', 'altis-reusable-blocks' ); 12 | 13 | return ( 14 | 15 | 16 | { `#${ id } - ${ itemTitle }` } 17 | 18 | { status === 'draft' && __( '(Draft)', 'altis-reusable-blocks' ) } 19 | { status === 'pending' && __( '(Pending)', 'altis-reusable-blocks' ) } 20 | 21 | ); 22 | }; 23 | 24 | RelationshipItem.propTypes = { 25 | id: PropTypes.number.isRequired, 26 | status: PropTypes.string.isRequired, 27 | title: PropTypes.shape( { 28 | rendered: PropTypes.string.isRequired, 29 | } ), 30 | }; 31 | 32 | export default RelationshipItem; 33 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - '*' 5 | 6 | name: Create Release 7 | 8 | jobs: 9 | release: 10 | name: Create Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/setup-node@v2 14 | with: 15 | node-version: '14' 16 | - name: Get the version 17 | id: get_version 18 | run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//} 19 | - name: Create Release 20 | uses: technote-space/release-github-actions@v7 21 | with: 22 | CLEAN_TEST_TAG: true 23 | CLEAN_TARGETS: .[!.]*,__tests__,src,package.json,yarn.lock,node_modules,tests,*.xml.dist 24 | COMMIT_MESSAGE: "Built release for ${{ steps.get_version.outputs.VERSION }}. For a full change log look at the notes within the original/${{ steps.get_version.outputs.VERSION }} release." 25 | CREATE_MAJOR_VERSION_TAG: false 26 | CREATE_MINOR_VERSION_TAG: false 27 | CREATE_PATCH_VERSION_TAG: false 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | ORIGINAL_TAG_PREFIX: original/ 30 | OUTPUT_BUILD_INFO_FILENAME: build.json 31 | TEST_TAG_PREFIX: test/ 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "altis-reusable-blocks", 3 | "private": true, 4 | "description": "Adds functionality to reusable blocks to enhance their usage.", 5 | "author": "Human Made Inc.", 6 | "license": "GPL-2.0-or-later", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/humanmade/altis-reusable-blocks.git" 10 | }, 11 | "bugs": "https://github.com/humanmade/altis-reusable-blocks/issues", 12 | "dependencies": { 13 | "lodash": "^4.17.19", 14 | "prop-types": "^15.7.2" 15 | }, 16 | "devDependencies": { 17 | "@humanmade/webpack-helpers": "^0.10.2", 18 | "@wordpress/eslint-plugin": "^3.3.0", 19 | "chalk": "^2.4.2", 20 | "concurrently": "^5.0.2", 21 | "eslint": "^6.8.0", 22 | "eslint-plugin-import": "^2.20.0", 23 | "eslint-plugin-jsdoc": "^20.3.1", 24 | "fs-extra": "^8.0.1", 25 | "node-sass": "^4.12.0", 26 | "webpack": "^4.41.0", 27 | "webpack-cli": "^3.3.9", 28 | "webpack-dev-server": "^3.11.2" 29 | }, 30 | "scripts": { 31 | "build": "webpack --config=.config/webpack.config.prod.js", 32 | "start": "webpack-dev-server --config=.config/webpack.config.dev.js", 33 | "lint": "concurrently \"npm run lint:php\" \"npm run lint:js\"", 34 | "lint:js": "eslint ./src", 35 | "lint:php": "./vendor/bin/phpcs --standard=phpcs.xml.dist", 36 | "test": "./vendor/bin/phpunit --testsuite unit" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/blocks/reusable-block/components/ListItem.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | 3 | import { BlockIcon } from '@wordpress/block-editor'; 4 | import { Button } from '@wordpress/components'; 5 | 6 | const icon = ( 7 | 18 | ); 19 | 20 | const ListItem = ( { id, onClick, onHover, title, ...props } ) => ( 21 |
  • 22 | 41 |
  • 42 | ); 43 | 44 | ListItem.propTypes = { 45 | id: PropTypes.number.isRequired, 46 | onClick: PropTypes.func.isRequired, 47 | onHover: PropTypes.func.isRequired, 48 | title: PropTypes.string.isRequired, 49 | }; 50 | 51 | export default ListItem; 52 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "humanmade/altis-reusable-blocks", 3 | "description": "Adds functionality to reusable blocks to enhance their usage.", 4 | "license": "GPL-2.0-or-later", 5 | "type": "wordpress-plugin", 6 | "require": { 7 | "php": ">=7.1", 8 | "composer/installers": "^1.7", 9 | "humanmade/asset-loader": "^0.5.0 || ^0.6.1" 10 | }, 11 | "require-dev": { 12 | "automattic/vipwpcs": "^2.0", 13 | "dealerdirect/phpcodesniffer-composer-installer": "^0.5.0", 14 | "phpcompatibility/phpcompatibility-wp": "^2.1", 15 | "squizlabs/php_codesniffer": "^3.4", 16 | "brain/monkey": "^2.3", 17 | "mockery/mockery": "^1.2", 18 | "phpunit/phpunit": "^7.5" 19 | }, 20 | "autoload-dev": { 21 | "psr-4": { 22 | "Altis\\ReusableBlocks\\Tests\\Unit\\": "tests/unit/", 23 | "Altis\\ReusableBlocks\\REST_API\\": "rest-api/", 24 | "Altis\\ReusableBlocks\\REST_API\\Relationships\\": "rest-api/relationships/", 25 | "Altis\\ReusableBlocks\\REST_API\\Search\\": "rest-api/search/" 26 | }, 27 | "files": [ 28 | "plugin.php", 29 | "inc/namespace.php", 30 | "inc/categories.php", 31 | "inc/connections.php", 32 | "inc/rest-api/namespace.php", 33 | "inc/rest-api/relationships/class-rest-endpoint.php", 34 | "inc/rest-api/search/class-rest-endpoint.php" 35 | ] 36 | }, 37 | "scripts": { 38 | "coverage": "phpunit --coverage-html coverage", 39 | "lint:phpcs": "phpcs", 40 | "test:unit": "phpunit --testsuite unit" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/blocks/reusable-block/components/Filter.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | 3 | import { 4 | SelectControl, 5 | TextControl, 6 | } from '@wordpress/components'; 7 | import { __ } from '@wordpress/i18n'; 8 | 9 | const Filter = ( { 10 | categoriesList, 11 | searchCategory, 12 | searchID, 13 | searchKeyword, 14 | updateSearchCategory, 15 | updateSearchKeyword, 16 | } ) => { 17 | const categoriesListOptions = categoriesList 18 | ? categoriesList.map( ( { id, name } ) => ( { 19 | label: name, 20 | value: id, 21 | } ) ) 22 | : []; 23 | 24 | if ( categoriesListOptions.length ) { 25 | categoriesListOptions.unshift( { label: '-- Select Category --', value: '' } ); 26 | } 27 | 28 | return ( 29 |
    30 | 37 | 45 |
    46 | ); 47 | }; 48 | 49 | Filter.propTypes = { 50 | categoriesList: PropTypes.array.isRequired, 51 | searchCategory: PropTypes.number, 52 | searchID: PropTypes.number, 53 | searchKeyword: PropTypes.string, 54 | updateSearchCategory: PropTypes.func.isRequired, 55 | updateSearchKeyword: PropTypes.func.isRequired, 56 | }; 57 | 58 | export default Filter; 59 | -------------------------------------------------------------------------------- /src/plugins/relationships/components/Pagination.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | 3 | import { PanelRow } from '@wordpress/components'; 4 | import { __, sprintf } from '@wordpress/i18n'; 5 | 6 | const Pagination = ( { 7 | currentPage, 8 | goToPrevPage, 9 | goToNextPage, 10 | totalPages, 11 | totalItems, 12 | } ) => { 13 | if ( totalPages < 2 ) { 14 | return null; 15 | } 16 | 17 | const pagingText = sprintf( __( '%d of %d', 'altis-reusable-blocks' ), currentPage, totalPages ); 18 | 19 | return ( 20 | 21 |
    22 | 23 | { sprintf( __( '%d items', 'altis-reusable-blocks' ), totalItems ) } 24 | 25 | 26 | { currentPage === 1 27 | ? ( ) 28 | : ( 29 | 35 | ) 36 | } 37 | { __( 'Current Page', 'altis-reusable-blocks' ) } 38 | 39 | { pagingText } 40 | 41 | { currentPage === totalPages 42 | ? ( ) 43 | : ( 44 | 50 | ) 51 | } 52 | 53 |
    54 |
    55 | ); 56 | }; 57 | 58 | Pagination.propTypes = { 59 | currentPage: PropTypes.number.isRequired, 60 | goToPrevPage: PropTypes.func.isRequired, 61 | goToNextPage: PropTypes.func.isRequired, 62 | totalPages: PropTypes.number.isRequired, 63 | totalItems: PropTypes.number.isRequired, 64 | }; 65 | 66 | export default Pagination; 67 | -------------------------------------------------------------------------------- /inc/categories.php: -------------------------------------------------------------------------------- 1 | __( 'Block Categories', 'altis-reusable-blocks' ), 18 | 'labels' => [ 19 | 'name' => _x( 'Block Categories', 'taxonomy general name', 'altis-reusable-blocks' ), 20 | 'singular_name' => _x( 'Block Category', 'taxonomy singular name', 'altis-reusable-blocks' ), 21 | 'search_items' => __( 'Search Block Categories', 'altis-reusable-blocks' ), 22 | 'popular_items' => __( 'Popular Block Categories', 'altis-reusable-blocks' ), 23 | 'all_items' => __( 'All Block Categories', 'altis-reusable-blocks' ), 24 | 'parent_item' => __( 'Parent Category', 'altis-reusable-blocks' ), 25 | 'parent_item_colon' => __( 'Parent Category:', 'altis-reusable-blocks' ), 26 | 'edit_item' => __( 'Edit Block Category', 'altis-reusable-blocks' ), 27 | 'update_item' => __( 'Update Block Category', 'altis-reusable-blocks' ), 28 | 'add_new_item' => __( 'Add New Block Category', 'altis-reusable-blocks' ), 29 | 'new_item_name' => __( 'New Block Category Name', 'altis-reusable-blocks' ), 30 | 'separate_items_with_commas' => __( 'Separate block categories with commas', 'altis-reusable-blocks' ), 31 | 'add_or_remove_items' => __( 'Add or remove block categories', 'altis-reusable-blocks' ), 32 | 'choose_from_most_used' => __( 'Choose from the most used block categories', 'altis-reusable-blocks' ), 33 | 'not_found' => __( 'No block categories found.', 'altis-reusable-blocks' ), 34 | 'menu_name' => __( 'Categories', 'altis-reusable-blocks' ), 35 | ], 36 | 'public' => false, 37 | 'publicly_queryable' => false, 38 | 'show_ui' => true, 39 | 'show_in_nav_menus' => false, 40 | 'show_in_rest' => true, 41 | 'hierarchical' => true, 42 | 'show_admin_column' => true, 43 | 'rewrite' => false, 44 | ] ); 45 | } 46 | -------------------------------------------------------------------------------- /tests/unit/CategoriesTest.php: -------------------------------------------------------------------------------- 1 | with( 'Altis\ReusableBlocks\Categories\register_block_categories' ); 21 | 22 | Testee\bootstrap(); 23 | } 24 | 25 | public function test_register_block_categories() { 26 | Functions\stubTranslationFunctions(); 27 | 28 | Functions\expect( 'register_taxonomy' ) 29 | ->with( 'wp_block_category', 'wp_block', [ 30 | 'label' => 'Block Categories', 31 | 'labels' => [ 32 | 'name' => 'Block Categories', 33 | 'singular_name' => 'Block Category', 34 | 'search_items' => 'Search Block Categories', 35 | 'popular_items' => 'Popular Block Categories', 36 | 'all_items' => 'All Block Categories', 37 | 'parent_item' => null, 38 | 'parent_item_colon' => null, 39 | 'edit_item' => 'Edit Block Category', 40 | 'update_item' => 'Update Block Category', 41 | 'add_new_item' => 'Add New Block Category', 42 | 'new_item_name' => 'New Block Category Name', 43 | 'separate_items_with_commas' => 'Separate block categories with commas', 44 | 'add_or_remove_items' => 'Add or remove block categories', 45 | 'choose_from_most_used' => 'Choose from the most used block categories', 46 | 'not_found' => 'No block categories found.', 47 | 'menu_name' => 'Categories', 48 | ], 49 | 'public' => false, 50 | 'publicly_queryable' => false, 51 | 'show_ui' => true, 52 | 'show_in_nav_menus' => false, 53 | 'show_in_rest' => true, 54 | 'hierarchical' => true, 55 | 'show_admin_column' => true, 56 | 'rewrite' => false, 57 | ] ) 58 | ->andReturn( true ); 59 | 60 | Testee\register_block_categories(); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Even though this is a JavaScript file, the config data itself is formatted as JSON to allow for easier comparison, 3 | * as well as copy-and-paste from and to other .eslintrc JSON files. 4 | */ 5 | 6 | const isProduction = process.env.NODE_ENV === 'production'; 7 | 8 | module.exports = { 9 | "root": true, 10 | "extends": [ 11 | "plugin:@wordpress/eslint-plugin/recommended", 12 | "plugin:import/errors" 13 | ], 14 | "env": { 15 | "browser": true 16 | }, 17 | "plugins": [ 18 | "jsdoc" 19 | ], 20 | "rules": { 21 | "@wordpress/dependency-group": "off", 22 | "@wordpress/react-no-unsafe-timeout": "error", 23 | "import/no-unresolved": [ 24 | "error", 25 | { 26 | "commonjs": true, 27 | "ignore": [ 28 | "^@wordpress\/[^/]+" 29 | ] 30 | } 31 | ], 32 | "jsdoc/check-param-names": "warn", 33 | "jsdoc/check-tag-names": "warn", 34 | "jsdoc/check-types": [ 35 | "warn", 36 | { 37 | "noDefaults": true 38 | } 39 | ], 40 | "jsdoc/newline-after-description": "warn", 41 | "jsdoc/no-undefined-types": "warn", 42 | "jsdoc/require-description-complete-sentence": "warn", 43 | "jsdoc/require-hyphen-before-param-description": "warn", 44 | "jsdoc/require-param": "warn", 45 | "jsdoc/require-param-description": "warn", 46 | "jsdoc/require-param-name": "warn", 47 | "jsdoc/require-param-type": "warn", 48 | "jsdoc/require-returns-type": "warn", 49 | "jsdoc/valid-types": "warn", 50 | "max-len": [ 51 | "warn", 52 | 120 53 | ], 54 | "no-console": isProduction ? "error" : "warn", 55 | "no-debugger": isProduction ? "error" : "warn", 56 | "no-plusplus": "off", 57 | "no-shadow": "off", 58 | "no-unused-vars": [ 59 | "warn", 60 | { 61 | "vars": "all", 62 | "varsIgnorePattern": "_", 63 | "args": "after-used", 64 | "argsIgnorePattern": "_", 65 | "ignoreRestSiblings": true 66 | } 67 | ], 68 | "operator-linebreak": [ 69 | "error", 70 | "before", 71 | { 72 | "overrides": { 73 | "=": "none" 74 | } 75 | } 76 | ], 77 | "react/prop-types": "warn", 78 | "valid-jsdoc": [ 79 | "off", 80 | {} 81 | ], 82 | 83 | // There is an issue with references not being detected when used as JSX component name (e.g., App in ). 84 | "@wordpress/no-unused-vars-before-return": "off", 85 | } 86 | }; 87 | -------------------------------------------------------------------------------- /src/styles.scss: -------------------------------------------------------------------------------- 1 | // Relationships list styles 2 | .altis-reusable-block-relationships__relationships_list.components-panel__body { 3 | &.is-opened { 4 | padding: 0; 5 | } 6 | 7 | .components-panel__row { 8 | margin: 0; 9 | padding: 10px 16px; 10 | min-height: 38px; 11 | 12 | &:nth-child( even ) { 13 | background: #F4F4F4; 14 | } 15 | 16 | &.tablenav { 17 | background: #fff; 18 | border-top: 1px solid #e2e4e7; 19 | height: auto; 20 | 21 | .tablenav-pages { 22 | float: none; 23 | margin: 0; 24 | } 25 | 26 | .tablenav-paging-text { 27 | margin: 0 10px; 28 | } 29 | } 30 | } 31 | 32 | .components-placeholder { 33 | background: transparent; 34 | } 35 | 36 | .components-spinner { 37 | margin: 0 auto; 38 | } 39 | } 40 | 41 | // Block display styles 42 | .block-editor-block-list__layout .block-editor-block-list__block[data-type="altis/reusable-block"] > .block-editor-block-list__block-edit::before { 43 | border: 1px dashed rgba(145, 151, 162, 0.25); 44 | } 45 | 46 | .block-editor-reusable-blocks-inserter { 47 | display: flex; 48 | flex-flow: row wrap; 49 | overflow: hidden; 50 | 51 | .block-editor-reusable-blocks-inserter__filter { 52 | width: 100%; 53 | flex-shrink: 0; 54 | display: flex; 55 | align-items: flex-start; 56 | 57 | .components-base-control { 58 | max-width: 30rem; 59 | width: 100%; 60 | margin-right: 1rem; 61 | margin-bottom: 16px; 62 | 63 | &__label { 64 | margin-right: 0.3rem; 65 | } 66 | 67 | .components-text-control__input { 68 | max-width: 25rem; 69 | } 70 | } 71 | } 72 | 73 | .block-editor-block-types-list { 74 | margin-left: 0; 75 | padding-left: 0; 76 | } 77 | 78 | @media ( max-width: 1280px ) { 79 | .block-editor-block-types-list__list-item { 80 | width: 50%; 81 | } 82 | } 83 | 84 | .block-editor-reusable-blocks-inserter__list, 85 | .block-editor-reusable-blocks-inserter__preview { 86 | flex: 1; 87 | width: 100%; 88 | padding: 1rem; 89 | overflow: scroll; 90 | max-height: 684px; 91 | } 92 | } 93 | 94 | // Hide the reusable blocks category except during search. 95 | .block-editor-inserter__reusable-blocks-panel { 96 | display: none; 97 | 98 | &.is-opened { 99 | display: block; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI Check 2 | on: 3 | push: 4 | branches: 5 | - '*' 6 | - '!gh-actions' 7 | 8 | jobs: 9 | build: 10 | name: Build and Check 11 | strategy: 12 | matrix: 13 | php: [7.3, 7.4] 14 | node: [12.9.1] 15 | runs-on: ubuntu-latest 16 | # each step has a 10 minute budget 17 | timeout-minutes: 10 18 | container: 19 | image: php:${{ matrix.php }}-apache 20 | env: 21 | NODE_ENV: development 22 | ports: 23 | - 80 24 | volumes: 25 | - ${{ github.workspace }}:/var/www/html 26 | steps: 27 | - name: Set up container 28 | run: | 29 | export DEBIAN_FRONTEND=noninteractive 30 | echo "Update package lists." 31 | apt-get -y update 32 | echo "Install base packages." 33 | apt-get -y --allow-downgrades --allow-remove-essential --allow-change-held-packages -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confnew install --fix-missing --fix-broken build-essential libssl-dev gnupg libfreetype6-dev libjpeg62-turbo-dev libmcrypt-dev libicu-dev libxml2-dev libonig-dev vim wget unzip git 34 | echo "Add yarn package repository." 35 | curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - 36 | echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list 37 | echo "Update package lists." 38 | apt-get -y update 39 | echo "Install NVM." 40 | curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.34.0/install.sh | bash 41 | . ~/.nvm/nvm.sh 42 | echo "Install node." 43 | nvm install ${{ matrix.node }} 44 | nvm use ${{ matrix.node }} 45 | echo "Install yarn." 46 | apt-get -y --allow-downgrades --allow-remove-essential --allow-change-held-packages -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confnew install --fix-missing --fix-broken yarn 47 | echo "Install composer." 48 | curl -sS https://getcomposer.org/installer | php -- --filename=composer.phar --version=1.10.20 49 | mv composer.phar /usr/local/bin/composer 50 | echo "Install PHP extensions." 51 | docker-php-ext-install -j$(nproc) iconv intl xml soap opcache pdo 52 | 53 | - name: Checkout repository 54 | uses: actions/checkout@v1 55 | with: 56 | ref: ${{ github.ref }} 57 | 58 | - name: Setup composer vendor folder cache 59 | uses: actions/cache@v1 60 | with: 61 | path: vendor 62 | key: composer-${{ hashFiles('composer.lock') }} 63 | 64 | - name: Setup yarn node_modules folder cache 65 | uses: actions/cache@v1 66 | with: 67 | path: node_modules 68 | key: node_modules-${{ hashFiles('yarn.lock') }} 69 | 70 | # Syntax check the PHP using 8 parallel threads 71 | - name: PHP Syntax Linting 72 | run: find . -path ./vendor -prune -o -name "*.php" -print0 | xargs -0 -n1 -P8 php -l 73 | 74 | - name: Run yarn install 75 | run: | 76 | yarn install 77 | 78 | - name: Run composer validate 79 | run: | 80 | composer validate 81 | 82 | - name: Run composer install 83 | run: | 84 | composer install --prefer-dist --no-progress --no-suggest 85 | 86 | - name: Run PHPUnit 87 | run: | 88 | composer test:unit 89 | 90 | - name: Run PHPCS Coding Standards 91 | run: | 92 | composer run lint:phpcs 93 | 94 | - name: Run ESLint 95 | run: | 96 | yarn lint:js 97 | 98 | - name: Run yarn build 99 | run: | 100 | yarn run build 101 | -------------------------------------------------------------------------------- /inc/rest-api/search/class-rest-endpoint.php: -------------------------------------------------------------------------------- 1 | namespace = 'altis-reusable-blocks/v1'; 41 | $this->rest_base = 'search'; 42 | } 43 | 44 | /** 45 | * Register Routes for Search Endpoint Request. 46 | */ 47 | public function register_routes() { 48 | 49 | register_rest_route( 50 | $this->namespace, 51 | $this->rest_base, 52 | [ 53 | 'methods' => 'GET', 54 | 'callback' => [ $this, 'get_items' ], 55 | 'schema' => [ new WP_REST_Blocks_Controller( 'wp_block' ), 'get_item_schema' ], 56 | 'permission_callback' => function() { 57 | return current_user_can( 'read_wp_block' ); 58 | }, 59 | 'args' => [ 60 | 'context' => [ 61 | 'default' => 'view', 62 | ], 63 | 'searchID' => [ 64 | 'required' => true, 65 | ], 66 | ], 67 | ] 68 | ); 69 | } 70 | 71 | /** 72 | * Fetches the reusable blocks of a single post by ID or a single reusable block by ID. 73 | * 74 | * @param WP_REST_Request $request Request object. 75 | * @return WP_REST_Response|WP_Error|Array REST_Response, WP_Error, or empty array. 76 | */ 77 | public function get_items( $request ) { 78 | $search_id = $request->get_param( 'searchID' ); 79 | 80 | if ( empty( $search_id ) ) { 81 | return new WP_Error( 82 | 'altis.reusable_blocks.no_search_id_provided', 83 | __( 'No `searchID` parameter provided.', 'altis-reusable-blocks' ), 84 | [ 'status' => 404 ] 85 | ); 86 | } 87 | 88 | if ( ! is_numeric( $search_id ) ) { 89 | return new WP_Error( 90 | 'altis.reusable_blocks.invalid_search_id_provided', 91 | __( 'Invalid `searchID` parameter provided.', 'altis-reusable-blocks' ), 92 | [ 'status' => 404 ] 93 | ); 94 | } 95 | 96 | if ( ! $post = get_post( intval( $search_id ) ) ) { 97 | // translators: %d is the search ID requested via REST API. 98 | return new WP_Error( 99 | 'altis.reusable_blocks.not_post_found', 100 | sprintf( __( 'The requested post ID of %d not found.', 'altis-reusable-blocks' ), $search_id ), 101 | [ 'status' => 404 ] 102 | ); 103 | } 104 | 105 | /** 106 | * If queried post is a reusable block, send that. 107 | * 108 | * Else, if the post type of the queried post supports reusable blocks, send all reusable blocks within that post. 109 | * 110 | * Otherwise, return empty array. 111 | */ 112 | if ( $post->post_type === 'wp_block' ) { 113 | $block_ids = [ $post->ID ]; 114 | } else if ( in_array( $post->post_type, Connections\get_post_types_with_reusable_blocks(), true ) ) { 115 | $blocks = array_filter( parse_blocks( $post->post_content ), function( $block ) { 116 | return $block['blockName'] === 'core/block'; 117 | } ); 118 | 119 | $block_ids = array_map( function( $block ) { 120 | return $block['attrs']['ref']; 121 | }, $blocks ); 122 | } else { 123 | return []; 124 | } 125 | 126 | $blocks_request = new WP_REST_Request( 'GET', '/wp/v2/blocks' ); 127 | $blocks_request->set_query_params( [ 128 | 'include' => $block_ids, 129 | 'posts_per_page' => 100 130 | ] ); 131 | 132 | // Parse request to get data. 133 | $response = rest_do_request( $blocks_request ); 134 | 135 | // Handle if error. 136 | if ( $response->is_error() ) { 137 | return new WP_Error( 138 | 'altis.reusable_blocks.rest_error_blocks_by_id', 139 | __( 'There was an error encountered when retrieving blocks from ID.', 'altis-reusable-blocks' ), 140 | [ 'status' => 404 ] 141 | ); 142 | } 143 | 144 | return $response; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/blocks/reusable-block/components/List.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | 3 | import { 4 | Placeholder, 5 | Spinner, 6 | } from '@wordpress/components'; 7 | import { Component } from '@wordpress/element'; 8 | 9 | import ListItem from './ListItem'; 10 | 11 | // Weighting variables to control sort results for matches on each individual search term. 12 | const TITLE_WEIGHT = 2; 13 | const CONTENT_WEIGHT = 0.5; 14 | 15 | // Weighting variables to control sort results for exact matches on the entire search term. 16 | const TITLE_EXACT_MATCH_WEIGHT = 5; 17 | const CONTENT_EXACT_MATCH_WEIGHT = 2.5; 18 | 19 | class List extends Component { 20 | state = { 21 | sortedBlocks: [], 22 | }; 23 | 24 | componentDidUpdate( prevProps ) { 25 | if ( this.props.filteredBlocksList !== prevProps.filteredBlocksList ) { 26 | this.sortBlocks(); 27 | } 28 | } 29 | 30 | /** 31 | * Get match counts for the keyword within the block title and content. 32 | * 33 | * @param {String} keyword - Search keyword. 34 | * @param {Object} block - Block data object. 35 | * 36 | * @return {Array} Title match count, Content match count. 37 | */ 38 | getMatchCounts = ( keyword, block ) => { 39 | const regex = new RegExp( keyword, 'ig' ); 40 | 41 | const titleMatches = block.title.match( regex ) || []; 42 | const contentMatches = block.content.match( regex ) || []; 43 | 44 | return [ titleMatches.length, contentMatches.length ]; 45 | }; 46 | 47 | /** 48 | * Sort the blocks with a custom weighting. 49 | * 50 | * Find the number of occurrences for each search term in both 51 | * the title and content, and use a custom weight to sort them. 52 | */ 53 | sortBlocks = () => { 54 | const { 55 | filteredBlocksList, 56 | searchID, 57 | searchKeywords, 58 | } = this.props; 59 | 60 | if ( searchKeywords && ! searchID ) { 61 | filteredBlocksList.sort( ( blockX, blockY ) => { 62 | let blockXCount = 0; 63 | let blockYCount = 0; 64 | 65 | /** 66 | * If keywords length is greater than 2, test for exact matches for the entire 67 | * search term within the title and content and weigh those more heavily. 68 | */ 69 | if ( searchKeywords.length > 2 ) { 70 | const [ 71 | titleXMatches, 72 | contentXMatches, 73 | ] = this.getMatchCounts( searchKeywords.join( ' ' ), blockX ); 74 | 75 | const [ 76 | titleYMatches, 77 | contentYMatches, 78 | ] = this.getMatchCounts( searchKeywords.join( ' ' ), blockY ); 79 | 80 | const titleXScore = titleXMatches * TITLE_EXACT_MATCH_WEIGHT; 81 | const contentXScore = contentXMatches * CONTENT_EXACT_MATCH_WEIGHT; 82 | 83 | const titleYScore = titleYMatches * TITLE_EXACT_MATCH_WEIGHT; 84 | const contentYScore = contentYMatches * CONTENT_EXACT_MATCH_WEIGHT; 85 | 86 | blockXCount += titleXScore + contentXScore; 87 | blockYCount += titleYScore + contentYScore; 88 | } 89 | 90 | // Loop through each string in searchKeywords, test for matches, and weigh those normally. 91 | searchKeywords.forEach( ( keyword ) => { 92 | const [ 93 | titleXMatches, 94 | contentXMatches, 95 | ] = this.getMatchCounts( keyword, blockX ); 96 | 97 | const [ 98 | titleYMatches, 99 | contentYMatches, 100 | ] = this.getMatchCounts( keyword, blockY ); 101 | 102 | const titleXScore = titleXMatches * TITLE_WEIGHT; 103 | const contentXScore = contentXMatches * CONTENT_WEIGHT; 104 | 105 | const titleYScore = titleYMatches * TITLE_WEIGHT; 106 | const contentYScore = contentYMatches * CONTENT_WEIGHT; 107 | 108 | blockXCount += titleXScore + contentXScore; 109 | blockYCount += titleYScore + contentYScore; 110 | } ); 111 | 112 | // Same weight, so sort by ID. 113 | if ( blockXCount === blockYCount ) { 114 | return blockX.id > blockY.id ? -1 : 1; 115 | } 116 | 117 | return blockXCount > blockYCount ? -1 : 1; 118 | } ); 119 | } 120 | 121 | this.setState( { sortedBlocks: filteredBlocksList } ); 122 | }; 123 | 124 | render() { 125 | const { 126 | isFetching, 127 | onHover, 128 | onItemSelect, 129 | } = this.props; 130 | 131 | const { 132 | sortedBlocks, 133 | } = this.state; 134 | 135 | return ( 136 |
    137 | { 138 | isFetching 139 | ? ( ) 140 | : ( 141 | 153 | ) 154 | } 155 |
    156 | ); 157 | } 158 | } 159 | 160 | List.propTypes = { 161 | filteredBlocksList: PropTypes.array.isRequired, 162 | isFetching: PropTypes.bool.isRequired, 163 | onItemSelect: PropTypes.func.isRequired, 164 | onHover: PropTypes.func.isRequired, 165 | searchID: PropTypes.number, 166 | searchKeywords: PropTypes.array.isRequired, 167 | }; 168 | 169 | export default List; 170 | -------------------------------------------------------------------------------- /src/plugins/relationships/components/Relationships.js: -------------------------------------------------------------------------------- 1 | import _uniqBy from 'lodash/uniqBy'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import { 5 | PanelBody, 6 | PanelRow, 7 | Placeholder, 8 | Spinner, 9 | } from '@wordpress/components'; 10 | import { PluginSidebar, PluginSidebarMoreMenuItem } from '@wordpress/edit-post'; 11 | import { Component, Fragment } from '@wordpress/element'; 12 | import { __ } from '@wordpress/i18n'; 13 | import { addQueryArgs } from '@wordpress/url'; 14 | 15 | import settings from '../../../settings'; 16 | import { fetchJson } from '../../../utils/fetch'; 17 | 18 | import Pagination from './Pagination'; 19 | import RelationshipItem from './RelationshipItem'; 20 | 21 | const { relationshipsPerPage } = settings; 22 | 23 | const baseClassName = 'altis-reusable-block-relationships'; 24 | const sidebarName = 'altis-reusable-block-relationships'; 25 | 26 | class Relationships extends Component { 27 | state = { 28 | relationshipsList: [], 29 | currentPage: 1, 30 | isFetching: false, 31 | totalPages: 0, 32 | totalItems: 0, 33 | }; 34 | 35 | /** 36 | * When component mounts, fetch relationships for the block. 37 | */ 38 | componentDidMount() { 39 | this.fetchRelationships(); 40 | } 41 | 42 | /** 43 | * Fetch all the posts that use the current block. 44 | * 45 | * @param {Number} page - Page number for the request. 46 | */ 47 | fetchRelationships = async ( page = 1 ) => { 48 | const { currentPostId } = this.props; 49 | 50 | this.setState( { isFetching: true } ); 51 | 52 | try { 53 | const data = await fetchJson( 54 | { 55 | path: addQueryArgs( 56 | `/altis-reusable-blocks/v1/relationships`, { 57 | block_id: currentPostId, 58 | page, 59 | } 60 | ), 61 | }, 62 | [ 'x-wp-totalpages', 'x-wp-total' ] 63 | ); 64 | 65 | this.updateRelationshipsList( data ); 66 | } catch ( e ) { 67 | /* eslint-disable no-console */ 68 | console.error( 'Error retrieving relationships for block.' ); 69 | console.error( e ); 70 | /* eslint-enable no-console */ 71 | } 72 | 73 | this.setState( { isFetching: false } ); 74 | }; 75 | 76 | /** 77 | * Update the Relationships List state object with a new list and normalize them. 78 | * 79 | * @param {Object[]} data - Array of new relationships fetched and response headers. 80 | * @param {Object[]} data.newRelationshipsList - Array of new relationships fetched. 81 | * @param {Object[]} data.headers - Array of response headers. 82 | */ 83 | updateRelationshipsList = ( [ newRelationshipsList, headers ] ) => { 84 | const { relationshipsList } = this.state; 85 | 86 | if ( ! newRelationshipsList.every( ( item ) => relationshipsList.includes( item ) ) ) { 87 | const totalPages = parseInt( headers[ 'x-wp-totalpages' ], 10 ); 88 | const totalItems = parseInt( headers[ 'x-wp-total' ], 10 ); 89 | 90 | this.setState( { 91 | relationshipsList: _uniqBy( [ ...relationshipsList, ...newRelationshipsList ], 'id' ), 92 | totalPages, 93 | totalItems, 94 | } ); 95 | } 96 | }; 97 | 98 | /** 99 | * Changes crurrentPage state to previous page. 100 | */ 101 | goToPrevPage = () => { 102 | const { currentPage } = this.state; 103 | 104 | if ( currentPage > 1 ) { 105 | this.setState( { 106 | currentPage: currentPage - 1, 107 | } ); 108 | } 109 | }; 110 | 111 | /** 112 | * Changes crurrentPage state to next page. 113 | */ 114 | goToNextPage = () => { 115 | const { 116 | currentPage, 117 | relationshipsList, 118 | totalPages, 119 | } = this.state; 120 | 121 | if ( currentPage < totalPages ) { 122 | this.setState( { 123 | currentPage: currentPage + 1, 124 | } ); 125 | 126 | if ( relationshipsList.length <= currentPage * relationshipsPerPage ) { 127 | this.fetchRelationships( currentPage + 1 ); 128 | } 129 | } 130 | }; 131 | 132 | render() { 133 | const { 134 | currentPage, 135 | isFetching, 136 | relationshipsList, 137 | totalPages, 138 | totalItems, 139 | } = this.state; 140 | 141 | const title = __( 'Relationships', 'altis-reusable-blocks' ); 142 | 143 | const startIndex = currentPage === 1 ? 0 : ( currentPage - 1 ) * relationshipsPerPage; 144 | 145 | const items = relationshipsList.slice( startIndex, relationshipsPerPage * currentPage ); 146 | const relationshipItems = items.length 147 | ? items.map( ( relationshipItem ) => { 148 | return ( ); 149 | } ) 150 | : { __( 'No Relationships to Display', 'altis-reusable-blocks' ) }; 151 | 152 | return ( 153 | 154 | 155 | { title } 156 | 157 | 161 | 162 | { 163 | isFetching 164 | ? 165 | : relationshipItems 166 | } 167 | 174 | 175 | 176 | 177 | ); 178 | } 179 | } 180 | 181 | Relationships.propTypes = { 182 | currentPostId: PropTypes.number.isRequired, 183 | }; 184 | 185 | export default Relationships; 186 | -------------------------------------------------------------------------------- /inc/namespace.php: -------------------------------------------------------------------------------- 1 | 'altis-reusable-blocks', 40 | 'scripts' => [ 41 | 'wp-api-fetch', 42 | 'wp-blocks', 43 | 'wp-components', 44 | 'wp-compose', 45 | 'wp-data', 46 | 'wp-edit-post', 47 | 'wp-editor', 48 | 'wp-element', 49 | 'wp-html-entities', 50 | 'wp-i18n', 51 | 'wp-plugins', 52 | 'wp-url', 53 | ] 54 | ] 55 | ); 56 | 57 | $settings = [ 58 | 'editPostUrl' => admin_url( 'post.php?post=%d&action=edit' ), 59 | 'context' => [ 60 | 'postId' => get_the_ID(), 61 | 'postType' => get_post_type(), 62 | ], 63 | 'relationshipsPerPage' => RELATIONSHIPS_PER_PAGE, 64 | ]; 65 | 66 | wp_localize_script( 'altis-reusable-blocks', 'altisReusableBlocksSettings', $settings ); 67 | } 68 | 69 | /** 70 | * Filter the allowed block types. If an array is provided, add `altis/reusable-block` to it, otherwise return the bool value that was passed in. 71 | * 72 | * @param bool|array $allowed_block_types Array of allowed block types or bool if it has not been filtered yet. 73 | * @return bool|array 74 | */ 75 | function filter_allowed_block_types( $allowed_block_types ) { 76 | if ( is_array( $allowed_block_types ) ) { 77 | $allowed_block_types[] = 'altis/reusable-block'; 78 | } 79 | 80 | return $allowed_block_types; 81 | } 82 | 83 | /** 84 | * Filter callback for `wp_insert_post_data`. Sets the post_name with the post_title for `wp_block` posts before inserting post data. 85 | * 86 | * @param array $data An array of slashed post data. 87 | * @param array $postarr An array of sanitized, but otherwise unmodified post data. 88 | * 89 | * @return array Filtered array of post data. 90 | */ 91 | function insert_reusable_block_post_data( array $data, array $postarr ) : array { 92 | if ( ! isset( $data['post_type'] ) || ! isset( $data['post_title'] ) ) { 93 | return $data; 94 | } 95 | 96 | if ( $data['post_type'] === BLOCK_POST_TYPE ) { 97 | $post_id = (int) $postarr['ID'] ?? 0; 98 | 99 | $data['post_name'] = wp_unique_post_slug( 100 | sanitize_title( $data['post_title'], $post_id ), 101 | $post_id, 102 | $data['post_status'], 103 | BLOCK_POST_TYPE, 104 | $data['post_parent'] ?? 0 105 | ); 106 | } 107 | 108 | return $data; 109 | } 110 | 111 | /** 112 | * Update the wp_block post type to display in the admin menu. 113 | * 114 | * @param array $args The post type creation args. 115 | * @param string $post_type The post type name. 116 | * @return array 117 | */ 118 | function show_wp_block_in_menu( array $args, string $post_type ) : array { 119 | if ( $post_type !== 'wp_block' ) { 120 | return $args; 121 | } 122 | 123 | if ( function_exists( 'wp_get_current_user' ) && ! current_user_can( 'edit_posts' ) ) { 124 | return $args; 125 | } 126 | 127 | $args['show_in_menu'] = true; 128 | $args['menu_position'] = 24; 129 | $args['menu_icon'] = 'dashicons-screenoptions'; 130 | $args['labels']['all_items'] = _x( 'All Reusable Blocks', 'post type menu label for all_items', 'altis' ); 131 | $args['labels']['name'] = _x( 'Reusable Blocks', 'post type menu label for name', 'altis' ); 132 | 133 | return $args; 134 | } 135 | 136 | /** 137 | * Add wp_block to main menu global var. 138 | * 139 | * Replicates wp-admin/menu.php line 103-163 without built in post type special cases. 140 | */ 141 | function admin_menu() { 142 | global $menu, $submenu, $_wp_last_object_menu; 143 | 144 | $ptype = 'wp_block'; 145 | 146 | $ptype_obj = get_post_type_object( $ptype ); 147 | 148 | // Check if it should be a submenu. 149 | if ( $ptype_obj->show_in_menu !== true ) { 150 | return false; 151 | } 152 | 153 | $ptype_menu_position = is_int( $ptype_obj->menu_position ) ? $ptype_obj->menu_position : ++$_wp_last_object_menu; // If we're to use $_wp_last_object_menu, increment it first. 154 | $ptype_for_id = sanitize_html_class( $ptype ); 155 | 156 | $menu_icon = 'dashicons-admin-post'; 157 | if ( is_string( $ptype_obj->menu_icon ) ) { 158 | // Special handling for data:image/svg+xml and Dashicons. 159 | if ( 0 === strpos( $ptype_obj->menu_icon, 'data:image/svg+xml;base64,' ) || 0 === strpos( $ptype_obj->menu_icon, 'dashicons-' ) ) { 160 | $menu_icon = $ptype_obj->menu_icon; 161 | } else { 162 | $menu_icon = esc_url( $ptype_obj->menu_icon ); 163 | } 164 | } 165 | 166 | $menu_class = 'menu-top menu-icon-' . $ptype_for_id; 167 | 168 | $ptype_file = "edit.php?post_type=$ptype"; 169 | $post_new_file = "post-new.php?post_type=$ptype"; 170 | $edit_tags_file = "edit-tags.php?taxonomy=%s&post_type=$ptype"; 171 | 172 | $ptype_menu_id = 'menu-posts-' . $ptype_for_id; 173 | 174 | /* 175 | * If $ptype_menu_position is already populated or will be populated 176 | * by a hard-coded value below, increment the position. 177 | */ 178 | $core_menu_positions = [ 59, 60, 65, 70, 75, 80, 85, 99 ]; 179 | while ( isset( $menu[ $ptype_menu_position ] ) || in_array( $ptype_menu_position, $core_menu_positions ) ) { 180 | $ptype_menu_position++; 181 | } 182 | 183 | // Disable globals sniff as it is safe to add to the menu and submenu globals. 184 | // phpcs:disable WordPress.WP.GlobalVariablesOverride.Prohibited 185 | $menu[ $ptype_menu_position ] = [ esc_attr( $ptype_obj->labels->menu_name ), $ptype_obj->cap->edit_posts, $ptype_file, '', $menu_class, $ptype_menu_id, $menu_icon ]; 186 | $submenu[ $ptype_file ][5] = [ $ptype_obj->labels->all_items, $ptype_obj->cap->edit_posts, $ptype_file ]; 187 | $submenu[ $ptype_file ][10] = [ $ptype_obj->labels->add_new, $ptype_obj->cap->create_posts, $post_new_file ]; 188 | 189 | $i = 15; 190 | foreach ( get_taxonomies( [], 'objects' ) as $tax ) { 191 | if ( ! $tax->show_ui || ! $tax->show_in_menu || ! in_array( $ptype, (array) $tax->object_type, true ) ) { 192 | continue; 193 | } 194 | 195 | $submenu[ $ptype_file ][ $i++ ] = [ esc_attr( $tax->labels->menu_name ), $tax->cap->manage_terms, sprintf( $edit_tags_file, $tax->name ) ]; 196 | } 197 | // phpcs:enable WordPress.WP.GlobalVariablesOverride.Prohibited 198 | } 199 | 200 | 201 | /** 202 | * Add Blocks to "Add New" menu. 203 | * 204 | * @param WP_Admin_Bar $wp_admin_bar 205 | */ 206 | function add_block_admin_bar_menu_items( \WP_Admin_Bar $wp_admin_bar ) { 207 | $wp_admin_bar->add_menu( 208 | [ 209 | 'parent' => 'new-content', 210 | 'id' => 'new-wp_block', 211 | 'title' => __( 'Reusable Block', 'altis-reusable-blocks' ), 212 | 'href' => admin_url( 'post-new.php?post_type=wp_block' ), 213 | ] 214 | ); 215 | } 216 | -------------------------------------------------------------------------------- /tests/unit/rest-api/search/RESTEndpointTest.php: -------------------------------------------------------------------------------- 1 | testee = new Testee(); 17 | } 18 | 19 | public function test_register_rest_routes() { 20 | Functions\stubTranslationFunctions(); 21 | Functions\stubEscapeFunctions(); 22 | 23 | $mock = \Mockery::mock( 'overload:' . \WP_REST_Blocks_Controller::class ); 24 | $mock->shouldReceive( 'get_item_schema' ); 25 | 26 | Functions\expect( 'register_rest_route' ) 27 | ->with( 28 | 'altis-reusable-blocks/v1', 29 | 'search', 30 | \Mockery::subset( 31 | [ 32 | 'methods' => 'GET', 33 | 'callback' => [ $this->testee, 'get_items' ], 34 | 'args' => [ 35 | 'context' => [ 36 | 'default' => 'view', 37 | ], 38 | 'searchID' => [ 39 | 'required' => true, 40 | ], 41 | ], 42 | ] 43 | ) 44 | ); 45 | 46 | $this->testee->register_routes(); 47 | } 48 | 49 | /** 50 | * Test if error is returned when not supplying a search ID in `$request` when running `$testee->get_items()`. 51 | */ 52 | public function test_get_items_empty_block_id() { 53 | Functions\stubTranslationFunctions(); 54 | 55 | $request = \Mockery::mock( 'overload:' . \WP_REST_Request::class ); 56 | $request->shouldReceive( 'get_param' ) 57 | ->with( 'searchID' ) 58 | ->once() 59 | ->andReturn( null ); 60 | 61 | \Mockery::mock( 'overload:' . \WP_Error::class ); 62 | 63 | $this->assertTrue( is_a( $this->testee->get_items( $request ), 'WP_Error' ) ); 64 | } 65 | 66 | /** 67 | * Test if error is returned when supplying an invalid search ID in `$request` when running `$testee->get_items()`. 68 | */ 69 | public function test_get_items_invalid_search_id() { 70 | Functions\stubTranslationFunctions(); 71 | 72 | $request = \Mockery::mock( 'overload:' . \WP_REST_Request::class ); 73 | $request->shouldReceive( 'get_param' ) 74 | ->with( 'searchID' ) 75 | ->once() 76 | ->andReturn( 'test' ); 77 | 78 | \Mockery::mock( 'overload:' . \WP_Error::class ); 79 | 80 | $this->assertTrue( is_a( $this->testee->get_items( $request ), 'WP_Error' ) ); 81 | } 82 | 83 | /** 84 | * Test if error is returned when an invalid post_id for search ID in `$request` when running `$testee->get_items()`. 85 | */ 86 | public function test_get_items_invalid_post_search_id() { 87 | Functions\stubTranslationFunctions(); 88 | 89 | $request = \Mockery::mock( 'overload:' . \WP_REST_Request::class ); 90 | $request->shouldReceive( 'get_param' ) 91 | ->with( 'searchID' ) 92 | ->once() 93 | ->andReturn( 1 ); 94 | 95 | Functions\expect( 'get_post' ) 96 | ->with( 1 ) 97 | ->andReturn( null ); 98 | 99 | \Mockery::mock( 'overload:' . \WP_Error::class ); 100 | 101 | $this->assertTrue( is_a( $this->testee->get_items( $request ), 'WP_Error' ) ); 102 | } 103 | 104 | /** 105 | * Test if response is returned when a valid post_id for search ID in `$request` when running `$testee->get_items()`. 106 | */ 107 | public function test_get_items_wp_block_post() { 108 | Functions\stubTranslationFunctions(); 109 | 110 | $request = \Mockery::mock( 'overload:' . \WP_REST_Request::class ); 111 | $request->shouldReceive( 'get_param' ) 112 | ->with( 'searchID' ) 113 | ->once() 114 | ->andReturn( 1 ); 115 | 116 | $request->shouldReceive( 'set_query_params' ) 117 | ->with( [ 118 | 'include' => [ 1 ], 119 | 'posts_per_page' => 100 120 | ] ) 121 | ->once(); 122 | 123 | $post = \Mockery::mock( \WP_Post::class ); 124 | $post->ID = 1; 125 | $post->post_type = 'wp_block'; 126 | 127 | Functions\expect( 'get_post' ) 128 | ->with( 1 ) 129 | ->andReturn( $post ); 130 | 131 | $response = \Mockery::mock( 'overload:' . \WP_REST_Response::class ); 132 | $response->shouldReceive( 'is_error' ) 133 | ->andReturn( false ); 134 | 135 | Functions\expect( 'rest_do_request' ) 136 | ->with( $request ) 137 | ->andReturn( $response ); 138 | 139 | $this->assertSame( $this->testee->get_items( $request ), $response ); 140 | } 141 | 142 | /** 143 | * Test if response is returned when a valid post_id for search ID in `$request` when running `$testee->get_items()`. 144 | */ 145 | public function test_get_items_non_wp_block_post() { 146 | Functions\stubTranslationFunctions(); 147 | 148 | $request = \Mockery::mock( 'overload:' . \WP_REST_Request::class ); 149 | $request->shouldReceive( 'get_param' ) 150 | ->with( 'searchID' ) 151 | ->once() 152 | ->andReturn( 1 ); 153 | 154 | $request->shouldReceive( 'set_query_params' ) 155 | ->with( [ 156 | 'include' => [ 2 ], 157 | 'posts_per_page' => 100 158 | ] ) 159 | ->once(); 160 | 161 | $post = \Mockery::mock( \WP_Post::class ); 162 | $post->ID = 1; 163 | $post->post_type = 'post'; 164 | $post->post_content = '

    Test123

    '; 165 | 166 | Functions\expect( 'get_post' ) 167 | ->with( 1 ) 168 | ->andReturn( $post ); 169 | 170 | $response = \Mockery::mock( 'overload:' . \WP_REST_Response::class ); 171 | $response->shouldReceive( 'is_error' ) 172 | ->andReturn( false ); 173 | 174 | Functions\expect( 'parse_blocks' ) 175 | ->with( $post->post_content ) 176 | ->andReturn( [ 177 | [ 178 | 'blockName' => 'core/block', 179 | 'attrs' => [ 180 | 'ref' => 2, 181 | ], 182 | ], 183 | [ 184 | 'blockName' => 'core/paragraph', 185 | ] 186 | ] ); 187 | 188 | Functions\expect( 'rest_do_request' ) 189 | ->with( $request ) 190 | ->andReturn( $response ); 191 | 192 | $this->assertSame( $this->testee->get_items( $request ), $response ); 193 | } 194 | 195 | /** 196 | * Test if response is returned when a valid post_id for search ID in `$request` but empty blocks, when running `$testee->get_items()`. 197 | */ 198 | public function test_get_items_invalid_post_type() { 199 | Functions\stubTranslationFunctions(); 200 | 201 | $request = \Mockery::mock( 'overload:' . \WP_REST_Request::class ); 202 | $request->shouldReceive( 'get_param' ) 203 | ->with( 'searchID' ) 204 | ->once() 205 | ->andReturn( 1 ); 206 | $post = \Mockery::mock( \WP_Post::class ); 207 | $post->ID = 1; 208 | $post->post_type = 'page'; 209 | $post->post_content = '

    Test123

    '; 210 | 211 | Functions\expect( 'get_post' ) 212 | ->with( 1 ) 213 | ->andReturn( $post ); 214 | 215 | $this->assertSame( $this->testee->get_items( $request ), [] ); 216 | } 217 | 218 | /** 219 | * Test if error is returned when the WP_REST_Response object has an error, in `$request` when running `$testee->get_items()`. 220 | */ 221 | public function test_get_items_is_error() { 222 | Functions\stubTranslationFunctions(); 223 | 224 | $request = \Mockery::mock( 'overload:' . \WP_REST_Request::class ); 225 | $request->shouldReceive( 'get_param' ) 226 | ->with( 'searchID' ) 227 | ->once() 228 | ->andReturn( 1 ); 229 | 230 | $request->shouldReceive( 'set_query_params' ) 231 | ->with( [ 232 | 'include' => [ 1 ], 233 | 'posts_per_page' => 100 234 | ] ) 235 | ->once(); 236 | 237 | $post = \Mockery::mock( \WP_Post::class ); 238 | $post->ID = 1; 239 | $post->post_type = 'wp_block'; 240 | 241 | Functions\expect( 'get_post' ) 242 | ->with( 1 ) 243 | ->andReturn( $post ); 244 | 245 | $response = \Mockery::mock( 'overload:' . \WP_REST_Response::class ); 246 | $response->shouldReceive( 'is_error' ) 247 | ->andReturn( true ); 248 | 249 | Functions\expect( 'rest_do_request' ) 250 | ->with( $request ) 251 | ->andReturn( $response ); 252 | 253 | \Mockery::mock( 'overload:' . \WP_Error::class ); 254 | 255 | $this->assertTrue( is_a( $this->testee->get_items( $request ), 'WP_Error' ) ); 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Altis Reusable Blocks 2 | 3 | Altis Reusable Blocks provides enterprise workflows and added functionality for reusable blocks. 4 | 5 | The main goals of Altis Reusable Blocks are to: 6 | 7 | * provide a much more seamless implementation of reusable blocks into enterprise-level setups and workflows. 8 | * provide an improved user interface that allows for better block discovery, including search and filtering. 9 | 10 | ![](https://github.com/humanmade/altis-reusable-blocks/workflows/CI%20Check/badge.svg) 11 | ![](https://img.shields.io/github/v/release/humanmade/altis-reusable-blocks) 12 | 13 | ---- 14 | 15 | ## Table of Contents 16 | 17 | * [Features](#features) 18 | * [Relationship and usage tracking](#relationship-and-usage-tracking) 19 | * [Admin Bar and Menu](#admin-bar-and-menu) 20 | * [Categories](#categories) 21 | * [Filtering](#filtering) 22 | * [Search](#search) 23 | * [Installation](#installation) 24 | * [Build Process](#build-process) 25 | * [Requirements](#requirements) 26 | * [Tests](#tests) 27 | * [PHP Tests](#php-tests) 28 | * [Usage](#usage) 29 | * [PHP Filters](#php-filters) 30 | * [`altis_post_types_with_reusable_blocks`](#altis_post_types_with_reusable_blocks) 31 | * [`rest_get_relationship_item_additional_fields_schema`](#rest_get_relationship_item_additional_fields_schema) 32 | * [`rest_prepare_relationships_response`](#rest_prepare_relationships_response) 33 | * [Release Process](#release-process) 34 | * [Versioning](#versioning) 35 | * [Publishing a Release](#publishing-a-release) 36 | 37 | ---- 38 | 39 | ## Features 40 | 41 | Altis Reusable Blocks includes new features and improvements both for the creation and the discovery/usage of reusable blocks. 42 | 43 | #### Relationship and usage tracking 44 | 45 | Keep track of all usages of reusable blocks within your posts. Within the edit screen for your reusable blocks, you will find the Relationships sidebar with a paginated view of all the posts that are using the reusable block that you are currently editing. 46 | 47 | On the reusable blocks post list table, you can see at a quick glance the usage count for that reusable block. 48 | 49 | #### Admin Bar and Menu 50 | 51 | By default, reusable blocks are somewhat hidden and can only be accessed from a submenu item in the block editor. 52 | With Altis Reusable Blocks, however, reusable blocks are upgraded to first-party citizens in the admin area. 53 | 54 | Like for every other content type, the admin menu on the left now contains a dedicated submenu for reusable blocks, offering shortcuts to see all existing reusable blocks, to create a new reusable block, and to see and manage categories, as well as any other publicly available taxonomy registered for reusable blocks. 55 | Also, the admin bar at the top now contains a shortcut to create a new reusable block, just like it is possible to do for posts, media, pages or users. 56 | 57 | #### Categories 58 | 59 | Just like posts or pages, reusable blocks can have one or more categories assigned to them. 60 | This helps in discovering relevant blocks by making use of the dedicated Category filter included in the block picker. 61 | 62 | #### Filtering 63 | 64 | When looking for an existing reusable block to insert into a post, the new block picker allows to search/filter based on a category. 65 | 66 | By default, the Category filter is set to the (main) category of the current post. 67 | However, this can be changed, without affecting the post's categories. 68 | 69 | #### Search 70 | 71 | In addition to the Category filter, the block picker also provides a search field. 72 | The search query is used to find reusable blocks with either a matching title or content, or both. 73 | Search results are sorted based on a smart algorithm using different weights for title matches vs. content matches, and exact matches vs. partial matches. 74 | As a result, more relevant blocks are displayed first. 75 | 76 | The search input also supports numeric ID lookups. 77 | By entering a block ID, the result set will be just that one according block, ready to be inserted. 78 | If the provided ID is a post ID, the results will be all reusable blocks referenced by that post, if any. 79 | 80 | ---- 81 | 82 | ## Installation 83 | 84 | Install with [Composer](https://getcomposer.org): 85 | 86 | ```sh 87 | composer require humanmade/altis-reusable-blocks 88 | ``` 89 | 90 | ### Build Process 91 | 92 | Create a **production** build: 93 | 94 | ```sh 95 | yarn build 96 | ``` 97 | 98 | Start the interactive **development** server: 99 | 100 | ```sh 101 | yarn start 102 | ``` 103 | 104 | ### Requirements 105 | 106 | This plugin requires PHP 7.1 or higher. 107 | 108 | ### Tests 109 | 110 | ### PHP Tests 111 | 112 | The PHP tests live in the `tests` folder, with subfolders for each individual test level. 113 | Currently, this means unit tests, living in `tests/unit`. 114 | 115 | Run the PHP unit tests: 116 | 117 | ```sh 118 | composer test:unit 119 | ``` 120 | 121 | Under the hood, this is using `PHPUnit`, as specified in the `composer.json` file. 122 | Any arguments passed to the script will then be passed on to the `phpunit` cli, meaning you can target specific files/names, like so: 123 | 124 | ```sh 125 | composer test:unit -- --filter logging 126 | ``` 127 | 128 | ---- 129 | 130 | ## Usage 131 | 132 | ### PHP Filters 133 | 134 | #### `altis_post_types_with_reusable_blocks` 135 | 136 | This filter allows the user to manipulate the post types that can use reusable blocks and should have the relationship for the shadow taxonomy. 137 | 138 | **Arguments:** 139 | 140 | * `$post_types` (`string[]`): List of post type slugs. 141 | 142 | **Usage Example:** 143 | 144 | ```php 145 | // Add the "page" post type. 146 | add_filter( 'altis_post_types_with_reusable_blocks', function ( aray $post_types ): array { 147 | 148 | $post_types[] = 'page'; 149 | 150 | return $post_types; 151 | } ); 152 | ``` 153 | 154 | ---- 155 | 156 | #### `rest_get_relationship_item_additional_fields_schema` 157 | 158 | This filter allows the user to modify the schema for the relationship data before it is returned from the REST API. 159 | 160 | **Arguments:** 161 | 162 | * `$schema` (`array`): Item schema data. 163 | 164 | **Usage Example:** 165 | 166 | ```php 167 | // Add the post author to the schema. 168 | add_filter( 'rest_get_relationship_item_additional_fields_schema', function ( array $additional_fields ): array { 169 | 170 | $additional_fields['author'] = [ 171 | 'description' => __( 'User ID for the author of the post.' ), 172 | 'type' => 'integer', 173 | 'context' => [ 'view' ], 174 | 'readonly' => true, 175 | ]; 176 | 177 | return $additional_fields; 178 | } ); 179 | ``` 180 | 181 | ---- 182 | 183 | #### `rest_prepare_relationships_response` 184 | 185 | This filter allows the user to modify the relationship data right before it is returned from the REST API. 186 | 187 | **Arguments:** 188 | 189 | * `$response` (`WP_REST_Response`): Response object. 190 | * `$post` (`WP_Post`): Post object. 191 | * `$request` (`WP_REST_Request`): Request object. 192 | 193 | **Usage Example:** 194 | 195 | ```php 196 | // Add the post author to the REST response. 197 | add_filter( 'rest_prepare_relationships_response', function ( WP_REST_Response $response, WP_Post $post ): WP_REST_Response { 198 | 199 | $response->data['author'] = $post->post_author; 200 | 201 | return $response; 202 | }, 10, 2 ); 203 | ``` 204 | 205 | ---- 206 | 207 | ## Release Process 208 | 209 | ### Versioning 210 | 211 | This plugin follows [Semantic Versioning](https://semver.org/). 212 | 213 | In a nutshell, this means that **patch releases**, for example, 1.2.3, only contain **backwards compatible bug fixes**. 214 | **Minor releases**, for example, 1.2.0, may contain **enhancements, new features, tests**, and pretty much everything that **does not break backwards compatibility**. 215 | Every **breaking change** to public APIs—be it renaming or even deleting structures intended for reuse, or making backwards-incompatible changes to certain functionality—warrants a **major release**, for example, 2.0.0. 216 | 217 | If you are using Composer to pull this plugin into your website build, choose your version constraint accordingly. 218 | 219 | ### Publishing a Release 220 | 221 | Release management is done using GitHub's built-in Releases functionality. 222 | Each release is tagged using the according version number, for example, the version 1.2.3 of this plugin would have the tag name `v1.2.3`. 223 | Releases should be created off the `master` branch, and tagged in the correct format. 224 | When a release is tagged in the correct format of `v*.*.*`, the GitHub actions release workflow creates a new built release based on the original release you just created. 225 | It will copy the tag's current state to a new tag of `original/v.*.*.*` and then build the project and push the built version to the original tag name `v*.*.*`. 226 | This allows composer to pull in a built version of the project without the need to run webpack to use it. 227 | 228 | For better information management, every release should come with complete, but high-level Release Notes, detailing all _New Features_, _Enhancements_, _Bug Fixes_ and potential other changes included in the according version. 229 | -------------------------------------------------------------------------------- /inc/rest-api/relationships/class-rest-endpoint.php: -------------------------------------------------------------------------------- 1 | namespace = 'altis-reusable-blocks/v1'; 45 | $this->rest_base = 'relationships'; 46 | } 47 | 48 | /** 49 | * Register relationship routes for WP API. 50 | */ 51 | public function register_routes() { 52 | register_rest_route( 53 | $this->namespace, 54 | $this->rest_base, 55 | [ 56 | [ 57 | 'methods' => 'GET', 58 | 'callback' => [ $this, 'get_items' ], 59 | 'permission_callback' => function() { 60 | return current_user_can( 'read_wp_block' ); 61 | }, 62 | 'args' => [ 63 | 'context' => [ 64 | 'default' => 'view', 65 | ], 66 | 'block_id' => [ 67 | 'description' => esc_html__( 'Block ID to get the relationship data for.', 'altis-reusable-blocks' ), 68 | 'required' => true, 69 | 'type' => 'integer', 70 | ], 71 | ], 72 | ], 73 | 'schema' => [ $this, 'get_item_schema' ], 74 | ] 75 | ); 76 | } 77 | 78 | /** 79 | * Gets the schema for a single relationship item. 80 | * 81 | * @return array $schema 82 | */ 83 | public function get_item_schema() : array { 84 | $schema = [ 85 | '$schema' => 'http://json-schema.org/draft-04/schema#', 86 | 'title' => __( 'Block relationships', 'altis-reusable-blocks' ), 87 | 'type' => 'object', 88 | 'properties' => [ 89 | 'id' => [ 90 | 'description' => __( 'Unique identifier for the object.' ), 91 | 'type' => 'integer', 92 | 'context' => [ 'view' ], 93 | 'readonly' => true, 94 | ], 95 | 'status' => [ 96 | 'description' => __( 'A named status for the object.' ), 97 | 'type' => 'string', 98 | 'enum' => array_keys( get_post_stati( [ 'internal' => false ] ) ), 99 | 'context' => [ 'view' ], 100 | 'readonly' => true, 101 | ], 102 | 'type' => [ 103 | 'description' => __( 'Type of Post for the object.' ), 104 | 'type' => 'string', 105 | 'context' => [ 'view' ], 106 | 'readonly' => true, 107 | ], 108 | 'title' => [ 109 | 'description' => __( 'The title for the object.' ), 110 | 'type' => 'object', 111 | 'context' => [ 'view' ], 112 | 'readonly' => true, 113 | 'properties' => [ 114 | 'rendered' => [ 115 | 'description' => __( 'HTML title for the object, transformed for display.' ), 116 | 'type' => 'string', 117 | 'context' => [ 'view' ], 118 | 'readonly' => true, 119 | ], 120 | ], 121 | ], 122 | ], 123 | ]; 124 | 125 | /** 126 | * Filters the additional fields array which starts as an empty array. 127 | * 128 | * @param array $additional_fields Array of schema data for additional fields to include in the REST response. 129 | */ 130 | $additional_fields = apply_filters( 'rest_get_relationship_item_additional_fields_schema', [] ); 131 | 132 | $schema['properties'] = array_merge( $schema['properties'], $additional_fields ); 133 | 134 | return $schema; 135 | } 136 | 137 | /** 138 | * Prepares a response for insertion into a collection. 139 | * 140 | * @param WP_REST_Response $response Response object. 141 | * @return array|mixed Response data, ready for insertion into collection data. 142 | */ 143 | public function prepare_response_for_collection( $response ) { 144 | if ( ! ( $response instanceof WP_REST_Response ) ) { 145 | return $response; 146 | } 147 | 148 | $data = (array) $response->get_data(); 149 | $server = rest_get_server(); 150 | $links = $server::get_compact_response_links( $response ); 151 | 152 | if ( ! empty( $links ) ) { 153 | $data['_links'] = $links; 154 | } 155 | 156 | return $data; 157 | } 158 | 159 | /** 160 | * Get related posts data. 161 | * 162 | * @param WP_REST_Request $request Request information from the request. 163 | * @return WP_REST_Response|Array|WP_Error REST response with relationship data if exists, empty array if no related posts, 164 | * or WP_Error if error is returned in query or REST requests for post data. 165 | */ 166 | public function get_items( $request ) { 167 | $block_id = $request->get_param( 'block_id' ); 168 | 169 | if ( empty( $block_id ) ) { 170 | return new WP_Error( 171 | 'altis.reusable_blocks.no_block_id_provided', 172 | __( 'No `block_id` parameter provided.', 'altis-reusable-blocks' ), 173 | [ 'status' => 404 ] 174 | ); 175 | } 176 | 177 | if ( ! $post = get_post( $block_id ) ) { 178 | // translators: %d is the post ID that relationships were requested via REST API. 179 | return new WP_Error( 180 | 'altis.reusable_blocks.not_block_found', 181 | sprintf( __( 'The requested post ID of %d not found.', 'altis-reusable-blocks' ), $block_id ), 182 | [ 'status' => 404 ] 183 | ); 184 | } 185 | 186 | if ( $post->post_type !== 'wp_block' ) { 187 | return new WP_Error( 188 | 'altis.reusable_blocks.not_block_post_type', 189 | __( 'The requested post ID was not of the post type `wp_block`.', 'altis-reusable-blocks' ), 190 | [ 'status' => 404 ] 191 | ); 192 | } 193 | 194 | $page = $request->get_param( 'page' ); 195 | 196 | $term_id = Connections\get_associated_term_id( $block_id ); 197 | 198 | // Return a blank array if no term_id is found. 199 | if ( ! $term_id ) { 200 | return []; 201 | } 202 | 203 | // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_tax_query 204 | $query_args = [ 205 | 'posts_per_page' => ReusableBlocks\RELATIONSHIPS_PER_PAGE, 206 | 'paged' => $page ?? 1, 207 | 'post_type' => Connections\get_post_types_with_reusable_blocks(), 208 | 'post_status' => 'any', 209 | 'tax_query' => [ 210 | [ 211 | 'taxonomy' => Connections\RELATIONSHIP_TAXONOMY, 212 | 'field' => 'term_id', 213 | 'terms' => $term_id, 214 | ] 215 | ] 216 | ]; 217 | // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_tax_query 218 | 219 | $posts_query = new WP_Query(); 220 | $query_result = $posts_query->query( $query_args ); 221 | $total_posts = $posts_query->found_posts; 222 | 223 | if ( ! $total_posts ) { 224 | return []; 225 | } 226 | 227 | $max_pages = ceil( $total_posts / ReusableBlocks\RELATIONSHIPS_PER_PAGE ); 228 | 229 | // Return error if requested invalid page number. 230 | if ( $page > $max_pages && $total_posts > 0 ) { 231 | return new WP_Error( 232 | 'rest_post_invalid_page_number', 233 | __( 'The page number requested is larger than the number of pages available.', 'altis-reusable-blocks' ), 234 | array( 'status' => 400 ) 235 | ); 236 | } 237 | 238 | $posts = []; 239 | 240 | foreach ( $query_result as $post ) { 241 | $data = $this->prepare_item_for_response( $post, $request ); 242 | $posts[] = $this->prepare_response_for_collection( $data ); 243 | } 244 | 245 | $response = rest_ensure_response( $posts ); 246 | 247 | // Handle if error. 248 | if ( $response->is_error() ) { 249 | return new WP_Error( 250 | 'altis.reusable_blocks.relationships_error', 251 | __( 'Encountered error retrieving relationship data.', 'altis-reusable-blocks' ), 252 | [ 'status' => 404 ] 253 | ); 254 | } 255 | 256 | // Add total and total pages headers. 257 | $response->header( 'X-WP-Total', (int) $total_posts ); 258 | $response->header( 'X-WP-TotalPages', (int) $max_pages ); 259 | 260 | $request_params = $request->get_query_params(); 261 | $base = add_query_arg( urlencode_deep( $request_params ), rest_url( "{$this->namespace}/{$this->rest_base}" ) ); 262 | 263 | if ( $page > 1 ) { 264 | $prev_page = $page - 1; 265 | $prev_link = add_query_arg( 'page', $prev_page, $base ); 266 | 267 | $response->link_header( 'prev', $prev_link ); 268 | } 269 | 270 | if ( $max_pages > $page ) { 271 | $next_page = $page + 1; 272 | $next_link = add_query_arg( 'page', $next_page, $base ); 273 | 274 | $response->link_header( 'next', $next_link ); 275 | } 276 | 277 | return $response; 278 | } 279 | 280 | /** 281 | * Prepares a single post output for response. 282 | * 283 | * @param WP_Post $post Post object. 284 | * @param WP_REST_Request $request Request object. 285 | * @return WP_REST_Response Response object. 286 | */ 287 | public function prepare_item_for_response( WP_Post $post, WP_REST_Request $request ) : WP_REST_Response { 288 | // Base fields for every post. 289 | $data = [ 290 | 'id' => $post->ID, 291 | 'status' => $post->post_status, 292 | 'type' => $post->post_type, 293 | 'title' => [ 294 | 'rendered' => get_the_title( $post->ID ), 295 | ], 296 | ]; 297 | 298 | // Wrap the data in a response object. 299 | $response = rest_ensure_response( $data ); 300 | 301 | /** 302 | * Filters the post data for a response. 303 | * 304 | * 305 | * @param WP_REST_Response $response The response object. 306 | * @param WP_Post $post Post object. 307 | * @param WP_REST_Request $request Request object. 308 | */ 309 | return apply_filters( 'rest_prepare_relationships_response', $response, $post, $request ); 310 | } 311 | 312 | } 313 | -------------------------------------------------------------------------------- /src/blocks/reusable-block/components/Edit.js: -------------------------------------------------------------------------------- 1 | import _debounce from 'lodash/debounce'; 2 | import _deburr from 'lodash/deburr'; 3 | import _isEqual from 'lodash/isEqual'; 4 | import _uniqBy from 'lodash/uniqBy'; 5 | import PropTypes from 'prop-types'; 6 | 7 | import { BlockPreview } from '@wordpress/block-editor'; 8 | import { createBlock } from '@wordpress/blocks'; 9 | import { dispatch } from '@wordpress/data'; 10 | import { Component } from '@wordpress/element'; 11 | import { __ } from '@wordpress/i18n'; 12 | import { addQueryArgs } from '@wordpress/url'; 13 | 14 | import List from './List'; 15 | import Filter from './Filter'; 16 | 17 | import { fetchJson } from '../../../utils/fetch'; 18 | 19 | class Edit extends Component { 20 | state = { 21 | blocksList: [], 22 | filteredBlocksList: [], 23 | hoveredId: null, 24 | inputText: '', 25 | isFetching: false, 26 | searchCategory: null, 27 | searchID: 0, 28 | searchKeyword: '', 29 | }; 30 | 31 | constructor( props ) { 32 | super( props ); 33 | 34 | // Debounce the fetchBlocks calls. 35 | this.fetchBlocks = _debounce( this.fetchBlocks, 1000 ); 36 | this.abortController = new AbortController(); 37 | } 38 | 39 | componentDidMount() { 40 | const { 41 | blocksList, 42 | isFetching, 43 | } = this.state; 44 | 45 | if ( ! blocksList.length && ! isFetching ) { 46 | this.fetchBlocks(); 47 | } 48 | } 49 | 50 | componentDidUpdate( prevProps, prevState ) { 51 | if ( 52 | this.state.searchCategory !== prevState.searchCategory 53 | || this.state.searchKeyword !== prevState.searchKeyword 54 | ) { 55 | this.fetchBlocks(); 56 | } 57 | } 58 | 59 | componentWillUnmount() { 60 | this.abortController.abort(); 61 | } 62 | 63 | fetchBlocks = () => { 64 | const { 65 | blocksList, 66 | searchID, 67 | } = this.state; 68 | 69 | if ( ! searchID ) { 70 | return this.fetchQueriedBlocks(); 71 | } 72 | 73 | const filteredBlock = blocksList.find( ( block ) => block.id === searchID ); 74 | 75 | // If block already exists in blocksList, just filter the list. 76 | if ( filteredBlock ) { 77 | this.setState( { filteredBlocksList: [ filteredBlock ] } ); 78 | } else { 79 | this.fetchQueriedBlocksByID(); 80 | } 81 | } 82 | 83 | /** 84 | * Fetches either the reusable blocks within a Post by post ID or fetch a single block by block ID. 85 | */ 86 | fetchQueriedBlocksByID = async () => { 87 | const { searchID } = this.state; 88 | 89 | this.setState( { isFetching: true } ); 90 | 91 | try { 92 | const [ data ] = await fetchJson( 93 | { 94 | path: addQueryArgs( '/altis-reusable-blocks/v1/search', { searchID } ), 95 | signal: this.abortController.signal, 96 | } 97 | ); 98 | 99 | this.updateBlocksList( data ); 100 | } catch ( e ) { 101 | /* eslint-disable no-console */ 102 | console.error( __( 'Error retrieving blocks by post or block ID.', 'altis-reusable-blocks' ) ); 103 | console.error( e ); 104 | /* eslint-enable no-console */ 105 | 106 | // Filter the block list with no blocks to match query. 107 | this.setState( { filteredBlocksList: [] } ); 108 | } 109 | 110 | this.setState( { isFetching: false } ); 111 | } 112 | 113 | /** 114 | * Fetch the most recently created blocks within the currently selected category for the post that is being edited. 115 | */ 116 | fetchQueriedBlocks = async () => { 117 | const { 118 | searchCategory, 119 | searchKeyword, 120 | } = this.state; 121 | 122 | this.setState( { isFetching: true } ); 123 | 124 | try { 125 | const queryArgs = { per_page: 100 }; 126 | 127 | if ( searchKeyword ) { 128 | queryArgs.search = searchKeyword; 129 | } 130 | 131 | if ( searchCategory ) { 132 | queryArgs.wp_block_category = searchCategory; 133 | } 134 | 135 | const [ data ] = await fetchJson( 136 | { 137 | path: addQueryArgs( '/wp/v2/blocks', queryArgs ), 138 | signal: this.abortController.signal, 139 | } 140 | ); 141 | 142 | this.updateBlocksList( data ); 143 | } catch ( e ) { 144 | /* eslint-disable no-console */ 145 | console.error( __( 'Error retrieving blocks.', 'altis-reusable-blocks' ) ); 146 | console.error( e ); 147 | /* eslint-enable no-console */ 148 | 149 | // Filter the block list with no blocks to match query. 150 | this.setState( { filteredBlocksList: [] } ); 151 | } 152 | 153 | this.setState( { isFetching: false } ); 154 | }; 155 | 156 | /** 157 | * Normalize an array of blocks into the format we want them in. 158 | * 159 | * @param {Object[]} blocks - Array of blocks. 160 | * 161 | * @return {Object[]} Normalized blocks. 162 | */ 163 | normalizeBlocks = ( blocks ) => { 164 | return blocks.map( ( block ) => ( { 165 | id: block.id, 166 | title: block.title.raw, 167 | content: block.content.raw, 168 | categories: block.wp_block_category, 169 | } ) ); 170 | }; 171 | 172 | /** 173 | * Update the Blocks List state object with a new list and normalize them. 174 | * 175 | * @param {Object[]} newBlocks - Array of new blocks fetched. 176 | */ 177 | updateBlocksList = ( newBlocks ) => { 178 | const { blocksList, searchID } = this.state; 179 | 180 | const normalizedNewBlocks = this.normalizeBlocks( newBlocks ); 181 | 182 | const newBlocksList = _uniqBy( [ ...blocksList, ...normalizedNewBlocks ], 'id' ); 183 | 184 | if ( ! _isEqual( newBlocksList, blocksList ) ) { 185 | this.setState( { blocksList: newBlocksList } ); 186 | } 187 | 188 | if ( searchID ) { 189 | return this.setState( { filteredBlocksList: normalizedNewBlocks } ); 190 | } 191 | 192 | this.filterBlocksList(); 193 | }; 194 | 195 | /** 196 | * Replace the current block with the `core/block` once we get the ID of that block. 197 | * 198 | * @param {Number} ref - Reference ID for the reusable block. 199 | */ 200 | replaceWithCoreBlock = ( ref ) => { 201 | const { clientId } = this.props; 202 | const { replaceBlock } = dispatch( 'core/block-editor' ); 203 | 204 | replaceBlock( clientId, createBlock( 'core/block', { ref } ) ); 205 | }; 206 | 207 | /** 208 | * Converts the search keyword into a normalized keyword. 209 | * 210 | * @param {string} keyword - The search keyword to normalize. 211 | * 212 | * @return {Array} The normalized search keywords with each keyword as an item in the array. 213 | */ 214 | normalizeSearchKeywords = ( keyword ) => { 215 | // Disregard diacritics. 216 | // Input: "média" 217 | keyword = _deburr( keyword ); 218 | 219 | // Accommodate leading slash, matching autocomplete expectations. 220 | // Input: "/media" 221 | keyword = keyword.replace( /^\//, '' ); 222 | 223 | // Strip leading and trailing whitespace. 224 | // Input: " media " 225 | keyword = keyword.trim(); 226 | 227 | return keyword.split( ' ' ); 228 | }; 229 | 230 | /** 231 | * Filter blocks list based on the selected category and search keyword. 232 | */ 233 | filterBlocksList = () => { 234 | const { 235 | blocksList, 236 | searchCategory, 237 | searchKeyword, 238 | } = this.state; 239 | 240 | if ( ! searchKeyword && ! searchCategory ) { 241 | return this.setState( { filteredBlocksList: blocksList } ); 242 | } 243 | 244 | const filteredBlocksList = blocksList.filter( ( block ) => { 245 | if ( searchCategory && ! block.categories.includes( searchCategory ) ) { 246 | return false; 247 | } 248 | 249 | if ( searchKeyword ) { 250 | // Split the keywords by spaces and then check each word. 251 | const searchKeywords = this.normalizeSearchKeywords( searchKeyword ); 252 | 253 | return searchKeywords.every( ( keyword ) => { 254 | // Check if keyword is excluded. 255 | const isExcludedKeyword = keyword.charAt( 0 ) === '-'; 256 | 257 | // If it is excluded, remove the dash prefix. 258 | const regex = new RegExp( isExcludedKeyword ? keyword.slice( 1 ) : keyword, 'ig' ); 259 | 260 | // Check that the post does not include the excluded keyword. 261 | if ( isExcludedKeyword ) { 262 | return ! regex.test( block.title ) && ! regex.test( block.content ); 263 | } 264 | 265 | return regex.test( block.title ) || regex.test( block.content ); 266 | } ); 267 | } 268 | 269 | return true; 270 | } ); 271 | 272 | this.setState( { filteredBlocksList } ); 273 | }; 274 | 275 | render() { 276 | const { 277 | filteredBlocksList, 278 | hoveredId, 279 | isFetching, 280 | searchCategory, 281 | searchKeyword, 282 | searchID, 283 | } = this.state; 284 | 285 | const { categoriesList } = this.props; 286 | 287 | return ( 288 |
    289 |
    290 | { 296 | searchCategory = searchCategory ? parseInt( searchCategory, 10 ) : null; 297 | this.setState( { searchCategory } ); 298 | } } 299 | updateSearchKeyword={ ( searchKeyword ) => { 300 | const searchID = /^[0-9]+$/.test( searchKeyword ) ? parseInt( searchKeyword, 10 ) : 0; 301 | 302 | this.setState( { 303 | searchKeyword, 304 | searchID, 305 | } ); 306 | } } 307 | /> 308 | this.setState( { hoveredId } ) } 313 | searchID={ searchID } 314 | searchKeywords={ this.normalizeSearchKeywords( searchKeyword ) } 315 | /> 316 |
    317 | { hoveredId && ( 318 |
    319 | 324 |
    325 | ) } 326 |
    327 |
    328 |
    329 | ); 330 | } 331 | } 332 | 333 | Edit.propTypes = { 334 | clientId: PropTypes.string, 335 | categoriesList: PropTypes.array, 336 | }; 337 | 338 | export default Edit; 339 | -------------------------------------------------------------------------------- /inc/connections.php: -------------------------------------------------------------------------------- 1 | false, 49 | 'show_ui' => false, 50 | 'meta_box_cb' => false, 51 | 'public' => false, 52 | ] 53 | ); 54 | } 55 | 56 | /** 57 | * Create new shadow term for any new `wp_block` post to allow for relationships if one does not exist. 58 | * 59 | * @param int $post_id Post ID to maybe create the shadow term for. 60 | * @param WP_Post $post Post object to maybe create the shadow term for. 61 | * 62 | * @return bool Whether or not the term was created. 63 | */ 64 | function maybe_create_shadow_term( int $post_id, WP_Post $post ) : bool { 65 | if ( $post->post_type !== BLOCK_POST_TYPE ) { 66 | return false; 67 | } 68 | 69 | if ( 'auto-draft' === $post->post_status ) { 70 | return false; 71 | } 72 | 73 | $term = get_associated_term( $post_id, RELATIONSHIP_TAXONOMY ); 74 | 75 | // If no term exists, create the term. 76 | if ( ! $term ) { 77 | return create_shadow_taxonomy_term( $post_id, $post, RELATIONSHIP_TAXONOMY ); 78 | } 79 | 80 | // Verify that the shadow term name and slug are in sync with the post title and slug. 81 | if ( shadow_term_in_sync( $term, $post ) ) { 82 | return false; 83 | } 84 | 85 | // If not, update the term. 86 | $term_data = wp_update_term( 87 | $term->term_id, 88 | RELATIONSHIP_TAXONOMY, 89 | [ 90 | 'name' => $post->post_title, 91 | 'slug' => $post->post_name, 92 | ] 93 | ); 94 | 95 | return ! is_wp_error( $term_data ); 96 | } 97 | 98 | /** 99 | * Creates the shadow term and set the term meta to create the association. 100 | * 101 | * @param int $post_id Post ID. 102 | * @param WP_Post $post WP Post Object. 103 | * 104 | * @return bool True if created or false if an error occurred. 105 | */ 106 | function create_shadow_taxonomy_term( int $post_id, WP_Post $post ) : bool { 107 | $shadow_term = wp_insert_term( 108 | $post->post_title, 109 | RELATIONSHIP_TAXONOMY, 110 | [ 111 | 'slug' => $post->post_name 112 | ] 113 | ); 114 | 115 | if ( is_wp_error( $shadow_term ) ) { 116 | return false; 117 | } 118 | 119 | $shadow_term_id = $shadow_term['term_id']; 120 | 121 | update_term_meta( $shadow_term_id, 'shadow_post_id', $post_id ); 122 | update_post_meta( $post_id, 'shadow_term_id', $shadow_term_id ); 123 | 124 | return true; 125 | } 126 | 127 | /** 128 | * Deletes a shadow taxonomy term before the associated post is deleted. 129 | * 130 | * @param int $post_id Post ID to delete the shadow taxonomy term for. 131 | * 132 | * @return bool True if successfully deleted, false if the post is the wrong post_type or if there is no associated term. 133 | */ 134 | function delete_shadow_term( int $post_id ) : bool { 135 | $post_type = get_post_type( $post_id ); 136 | 137 | if ( $post_type !== BLOCK_POST_TYPE ) { 138 | return false; 139 | } 140 | 141 | $term = get_associated_term( $post_id, RELATIONSHIP_TAXONOMY ); 142 | 143 | if ( ! $term ) { 144 | return false; 145 | } 146 | 147 | $term_deleted = wp_delete_term( $term->term_id, RELATIONSHIP_TAXONOMY ); 148 | 149 | return ! is_wp_error( $term_deleted ); 150 | } 151 | 152 | /** 153 | * Gets the associated post object for a given term_id. 154 | * 155 | * @param int $term_id Term ID to retreive the associated post object for. 156 | * 157 | * @return WP_Post|null The associated post object or null if no post is found. 158 | */ 159 | function get_associated_post( int $term_id ) { 160 | $post_id = get_associated_post_id( $term_id ); 161 | 162 | return get_post( $post_id ); 163 | } 164 | 165 | /** 166 | * Gets the associated shadow post_id of a given term_id. 167 | * 168 | * @param int $term_id Term ID to retreive the post_id for. 169 | * 170 | * @return int The post_id or 0 if no associated post is found. 171 | */ 172 | function get_associated_post_id( int $term_id ) : int { 173 | $post_id = get_term_meta( $term_id, 'shadow_post_id', true ); 174 | 175 | return $post_id ? intval( $post_id ) : 0; 176 | } 177 | 178 | /** 179 | * Gets the associated term object for a given post_id. 180 | * 181 | * @param int $post_id Post ID to retreive the associated term object for. 182 | * 183 | * @return bool|WP_Term Returns the associated term object or false if no term is found. 184 | */ 185 | function get_associated_term( int $post_id ) { 186 | $term_id = get_associated_term_id( $post_id ); 187 | 188 | return get_term_by( 'id', $term_id, RELATIONSHIP_TAXONOMY ); 189 | } 190 | 191 | /** 192 | * Gets the associated shadow term ID of a given post object 193 | * 194 | * @param int $post Post ID to get shadow term for. 195 | * 196 | * @return int The term_id or 0 if no associated term was found. 197 | */ 198 | function get_associated_term_id( int $post_id ) : int { 199 | $shadow_term_id = get_post_meta( $post_id, 'shadow_term_id', true ); 200 | 201 | return $shadow_term_id ? intval( $shadow_term_id ) : 0; 202 | } 203 | 204 | /** 205 | * Checks to see if the current term and its associated post have the same title and slug. 206 | * While we generally rely on term and post meta to track association, it is important that these two value stay synced. 207 | * 208 | * @param WP_Term $term Term object to check. 209 | * @param WP_Post $post Post object to check. 210 | * 211 | * @return bool True if a match is found, or false if no match is found. 212 | */ 213 | function shadow_term_in_sync( WP_Term $term, WP_Post $post ) : bool { 214 | return ( $term->name === $post->post_title && $term->slug === $post->post_name ); 215 | } 216 | 217 | /** 218 | * Parse the post content to find reusable blocks and set the relationship 219 | * with the shadow taxonomy term for each reusable block. 220 | * 221 | * @param int $post_id The ID of the post that has been updated. 222 | * @param WP_Post $post_after New state of the post data. 223 | * @param WP_Post $post_before Old state of the post data. 224 | * 225 | * @return bool False if post is an invalid post type, the content from $post_before and $post_after 226 | * are the same, or if there are no reusable blocks. True if the object terms are set successfully. 227 | */ 228 | function synchronize_associated_terms( int $post_id, WP_Post $post_after, WP_Post $post_before ) : bool { 229 | if ( ! in_array( $post_after->post_type, get_post_types_with_reusable_blocks(), true ) ) { 230 | return false; 231 | } 232 | 233 | if ( $post_after->post_content === $post_before->post_content ) { 234 | return false; 235 | } 236 | 237 | // Get all the reusable blocks from the content. 238 | $reusable_blocks = array_reduce( 239 | parse_blocks( $post_after->post_content ), 240 | function( $blocks, $block ) { 241 | if ( $block['blockName'] !== 'core/block' ) { 242 | return $blocks; 243 | } 244 | 245 | $blocks[] = $block['attrs']; 246 | return $blocks; 247 | }, 248 | [] 249 | ); 250 | 251 | if ( empty( $reusable_blocks ) ) { 252 | $terms_set = wp_set_object_terms( $post_id, null, RELATIONSHIP_TAXONOMY ); 253 | 254 | return ! is_wp_error( $terms_set ); 255 | } 256 | 257 | $shadow_term_ids = []; 258 | 259 | // Loop through the reusable blocks and get the shadow term ID of the block. 260 | foreach ( $reusable_blocks as $block ) { 261 | $block_post_id = $block['ref']; 262 | $shadow_term_ids[] = get_associated_term_id( $block_post_id ); 263 | 264 | // Delete usage count cache. 265 | wp_cache_delete( sprintf( BLOCK_USAGE_COUNT_CACHE_KEY_FORMAT, $block_post_id ) ); 266 | } 267 | 268 | // Set the post relationships to the shadow terms. 269 | $terms_set = wp_set_object_terms( $post_id, $shadow_term_ids, RELATIONSHIP_TAXONOMY ); 270 | 271 | return ! is_wp_error( $terms_set ); 272 | } 273 | 274 | /** 275 | * Adds the usage count column to the `wp_block` post list table. 276 | * 277 | * @param array $columns - Columns to be filtered. 278 | * 279 | * @return array - Filtered columns. 280 | */ 281 | function manage_wp_block_posts_columns( array $columns ) : array { 282 | unset( $columns['date'] ); 283 | $columns['usage-count'] = __( 'Usage Count', 'altis-reusable-blocks' ); 284 | $columns['date'] = __( 'Date', 'altis-reusable-blocks' ); 285 | 286 | return $columns; 287 | } 288 | 289 | /** 290 | * Renders the usage count for the post list table custom column. 291 | * 292 | * @param string $column - Custom column slug name. 293 | * @param int $post_id - Post to display data for. 294 | */ 295 | function usage_column_output( $column, $post_id ) { 296 | if ( $column !== 'usage-count' ) { 297 | return; 298 | } 299 | 300 | $term_id = get_associated_term_id( $post_id ); 301 | 302 | // Return a blank array if no term_id is found. 303 | if ( ! $term_id ) { 304 | return; 305 | } 306 | 307 | $cache_key = sprintf( BLOCK_USAGE_COUNT_CACHE_KEY_FORMAT, $post_id ); 308 | 309 | $count = wp_cache_get( $cache_key ); 310 | 311 | if ( $count === false ) { 312 | // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_tax_query 313 | $query_args = [ 314 | 'posts_per_page' => -1, 315 | 'fields' => 'ids', 316 | 'no_found_rows' => true, 317 | 'post_type' => get_post_types_with_reusable_blocks(), 318 | 'post_status' => 'any', 319 | 'tax_query' => [ 320 | [ 321 | 'taxonomy' => RELATIONSHIP_TAXONOMY, 322 | 'field' => 'term_id', 323 | 'terms' => $term_id, 324 | ] 325 | ] 326 | ]; 327 | // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_tax_query 328 | 329 | $query = new WP_Query(); 330 | $query_results = $query->query( $query_args ); 331 | $count = $query->post_count; 332 | 333 | // Cache for 8 hours since we are busting the cache any time the usage gets changed. 334 | wp_cache_set( $cache_key, $count, '', 8 * 60 * 60 ); 335 | } 336 | 337 | printf( '%d', esc_url( get_edit_post_link( $post_id ) ), esc_html( $count ) ); 338 | } 339 | -------------------------------------------------------------------------------- /tests/unit/NamespaceTest.php: -------------------------------------------------------------------------------- 1 | with( 'Altis\ReusableBlocks\enqueue_block_editor_assets' ); 24 | 25 | Actions\expectAdded( 'admin_bar_menu' ) 26 | ->with( 'Altis\ReusableBlocks\add_block_admin_bar_menu_items', 100 ); 27 | 28 | Actions\expectAdded( 'admin_menu' ) 29 | ->with( 'Altis\ReusableBlocks\admin_menu', 9 ); 30 | 31 | Filters\expectAdded( 'wp_insert_post_data' ) 32 | ->with( 'Altis\ReusableBlocks\insert_reusable_block_post_data', 10, 2 ); 33 | 34 | Filters\expectAdded( 'register_post_type_args' ) 35 | ->with( 'Altis\ReusableBlocks\show_wp_block_in_menu', 10, 2 ); 36 | 37 | Filters\expectAdded( 'allowed_block_types_all' ) 38 | ->with( 'Altis\ReusableBlocks\filter_allowed_block_types', 20 ); 39 | 40 | Testee\bootstrap(); 41 | } 42 | 43 | public function test_enqueue_block_editor_assets() { 44 | Functions\expect( 'plugin_dir_path' ) 45 | ->with( 46 | Testee\PLUGIN_FILE 47 | ) 48 | ->andReturn( 'filepath/' ); 49 | 50 | Functions\expect( 'Asset_Loader\enqueue_asset' ) 51 | ->with( 52 | 'filepath/build/asset-manifest.json', 53 | 'index.js', 54 | [ 55 | 'handle' => 'altis-reusable-blocks', 56 | 'scripts' => [ 57 | 'wp-api-fetch', 58 | 'wp-blocks', 59 | 'wp-components', 60 | 'wp-compose', 61 | 'wp-data', 62 | 'wp-edit-post', 63 | 'wp-editor', 64 | 'wp-element', 65 | 'wp-html-entities', 66 | 'wp-i18n', 67 | 'wp-plugins', 68 | 'wp-url', 69 | ] 70 | ] 71 | ); 72 | 73 | Functions\expect( 'admin_url' ) 74 | ->with( 'post.php?post=%d&action=edit' ) 75 | ->andReturn( 'http://altis.local/wp-admin/post.php?post=%d&action=edit' ); 76 | 77 | Functions\expect( 'get_the_ID' ) 78 | ->andReturn( 1 ); 79 | 80 | Functions\expect( 'get_post_type' ) 81 | ->andReturn( 'wp_block' ); 82 | 83 | Functions\expect( 'wp_localize_script' ) 84 | ->with( 85 | 'altis-reusable-blocks', 86 | 'altisReusableBlocksSettings', 87 | [ 88 | 'editPostUrl' => 'http://altis.local/wp-admin/post.php?post=%d&action=edit', 89 | 'context' => [ 90 | 'postId' => 1, 91 | 'postType' => 'wp_block', 92 | ], 93 | 'relationshipsPerPage' => Testee\RELATIONSHIPS_PER_PAGE, 94 | ] 95 | ) 96 | ->andReturn( true ); 97 | 98 | Testee\enqueue_block_editor_assets(); 99 | } 100 | 101 | /** 102 | * Tests `filter_allowed_block_types` to ensure we're enabling the block type successfully when a bool is passed in. 103 | * 104 | * @return void 105 | */ 106 | public function test_filter_allowed_block_types_bool() { 107 | $this->assertTrue( Testee\filter_allowed_block_types( true ) ); 108 | } 109 | 110 | /** 111 | * Tests `filter_allowed_block_types` to ensure we're enabling the block type successfully when an array is passed in. 112 | * 113 | * @return void 114 | */ 115 | public function test_filter_allowed_block_types_array() { 116 | $this->assertSame( Testee\filter_allowed_block_types( [] ), [ 'altis/reusable-block' ] ); 117 | } 118 | 119 | /** 120 | * Tests `insert_reusable_block_post_data` with an invalid post type. 121 | * 122 | * @return void 123 | */ 124 | public function test_insert_reusable_block_post_data_invalid_post_type() { 125 | $data = [ 126 | 'post_type' => POST_POST_TYPE, 127 | 'post_title' => 'Test Block Title' 128 | ]; 129 | 130 | $new_data = Testee\insert_reusable_block_post_data( $data, [ 'ID' => 1 ] ); 131 | 132 | $this->assertSame( $data, $new_data ); 133 | } 134 | 135 | /** 136 | * Tests `insert_reusable_block_post_data` with invalid post data (type or title missing). 137 | * 138 | * @return void 139 | */ 140 | public function test_insert_reusable_block_post_data_invalid_post_data() { 141 | $data = [ 142 | 'post_status' => 'auto-draft' 143 | ]; 144 | 145 | $new_data = Testee\insert_reusable_block_post_data( $data, [] ); 146 | 147 | $this->assertSame( $data, $new_data ); 148 | } 149 | 150 | /** 151 | * Tests `insert_reusable_block_post_data` with the valid post type. 152 | * 153 | * @return void 154 | */ 155 | public function test_insert_reusable_block_post_data_valid_data() { 156 | $data = [ 157 | 'post_type' => Testee\BLOCK_POST_TYPE, 158 | 'post_title' => 'Test Block Title', 159 | 'post_status' => 'publish' 160 | ]; 161 | 162 | $post_name = 'test-block-title'; 163 | 164 | Functions\expect( 'sanitize_title' ) 165 | ->with( $data['post_title'] ) 166 | ->andReturn( $post_name ); 167 | 168 | Functions\expect( 'wp_unique_post_slug' ) 169 | ->with( 170 | $post_name, 171 | 1, 172 | 'publish', 173 | Testee\BLOCK_POST_TYPE, 174 | 0 175 | ) 176 | ->andReturn( $post_name ); 177 | 178 | $new_data = Testee\insert_reusable_block_post_data( $data, [ 'ID' => 1 ] ); 179 | 180 | $this->assertSame( $post_name, $new_data['post_name'] ); 181 | } 182 | 183 | /** 184 | * Tests `add_block_admin_bar_menu_item`. 185 | * 186 | * @return void 187 | */ 188 | public function test_add_block_admin_bar_menu_items() { 189 | Functions\stubTranslationFunctions(); 190 | 191 | $new_post_url = 'https://altis.local/wordpress/wp-admin/post-new.php?post_type=wp_block'; 192 | 193 | Functions\expect( 'admin_url' ) 194 | ->with( 'post-new.php?post_type=wp_block' ) 195 | ->andReturn( $new_post_url ); 196 | 197 | 198 | $admin_bar = \Mockery::mock( \WP_Admin_Bar::class ); 199 | $admin_bar->shouldReceive( 'add_menu' ) 200 | ->with( [ 201 | 'parent' => 'new-content', 202 | 'id' => 'new-wp_block', 203 | 'title' => 'Reusable Block', 204 | 'href' => $new_post_url, 205 | ] ) 206 | ->once(); 207 | 208 | Testee\add_block_admin_bar_menu_items( $admin_bar ); 209 | } 210 | 211 | /** 212 | * Test `show_wp_block_in_menu` if invalid post type is being filtered. 213 | * 214 | * @return void 215 | */ 216 | public function test_show_wp_block_in_menu_invalid_post_type() { 217 | Functions\stubTranslationFunctions(); 218 | 219 | $this->assertSame( [], Testee\show_wp_block_in_menu( [], 'post' ) ); 220 | } 221 | 222 | /** 223 | * Test `show_wp_block_in_menu` if correct args are returned. 224 | * 225 | * @return void 226 | */ 227 | public function test_show_wp_block_in_menu() { 228 | Functions\stubTranslationFunctions(); 229 | 230 | $args = []; 231 | $expected_args = [ 232 | 'show_in_menu' => true, 233 | 'menu_position' => 24, 234 | 'menu_icon' => 'dashicons-screenoptions', 235 | ]; 236 | 237 | $actual_args = Testee\show_wp_block_in_menu( $args, 'wp_block' ); 238 | 239 | foreach ( $expected_args as $key => $value ) { 240 | $this->assertArrayHasKey( $key, $actual_args ); 241 | $this->assertSame( $value, $actual_args[ $key ] ); 242 | } 243 | } 244 | 245 | /** 246 | * Test `show_wp_block_in_menu` if user has no capability to manage Blocks. 247 | * 248 | * @return void 249 | */ 250 | public function test_show_wp_block_in_menu_no_caps() { 251 | Functions\expect( 'wp_get_current_user' ) 252 | ->never(); 253 | 254 | Functions\expect( 'current_user_can' ) 255 | ->with( 'edit_posts' ) 256 | ->andReturn( false ); 257 | 258 | $this->assertSame( [], Testee\show_wp_block_in_menu( [], 'wp_block' ) ); 259 | } 260 | 261 | /** 262 | * Tests `admin_menu` if `show_in_menu` was filtered to be false. 263 | * 264 | * @return void 265 | */ 266 | public function test_admin_menu_show_in_menu_false() { 267 | $post_type_obj = (object) [ 268 | 'show_in_menu' => false, 269 | ]; 270 | 271 | Functions\expect( 'get_post_type_object' ) 272 | ->with( 'wp_block' ) 273 | ->andReturn( $post_type_obj ); 274 | 275 | $this->assertFalse( Testee\admin_menu() ); 276 | } 277 | 278 | /** 279 | * Tests `admin_menu` additions with Blocks menu. 280 | * 281 | * @return void 282 | */ 283 | public function test_admin_menu() { 284 | global $menu, $submenu, $_wp_last_object_menu; 285 | 286 | // phpcs:disable WordPress.WP.GlobalVariablesOverride.Prohibited 287 | $menu = []; 288 | 289 | $submenu = []; 290 | 291 | $_wp_last_object_menu = 19; 292 | // phpcs:enable WordPress.WP.GlobalVariablesOverride.Prohibited 293 | 294 | $post_type_obj = (object) [ 295 | 'cap' => (object) [ 296 | 'edit_posts' => 'edit_wp_block', 297 | 'create_posts' => 'create_wp_block', 298 | ], 299 | 'labels' => (object) [ 300 | 'menu_name' => 'Blocks', 301 | 'add_new' => 'Add New Block', 302 | 'all_items' => 'All Blocks', 303 | ], 304 | 'show_in_menu' => true, 305 | 'menu_position' => 20, 306 | 'menu_icon' => 'dashicons-fake-icon', 307 | ]; 308 | 309 | $fake_taxonomy_obj = (object) [ 'show_ui' => false ]; 310 | 311 | $cat_taxonomy_obj = (object) [ 312 | 'cap' => (object) [ 313 | 'manage_terms' => 'manage_category', 314 | ], 315 | 'labels' => (object) [ 316 | 'menu_name' => 'Categories', 317 | ], 318 | 'name' => 'category', 319 | 'object_type' => [ 'post', 'wp_block' ], 320 | 'show_ui' => true, 321 | 'show_in_menu' => true, 322 | ]; 323 | 324 | Functions\stubEscapeFunctions(); 325 | 326 | Functions\expect( 'get_post_type_object' ) 327 | ->with( 'wp_block' ) 328 | ->andReturn( $post_type_obj ); 329 | 330 | Functions\expect( 'sanitize_html_class' ) 331 | ->with( 'wp_block' ) 332 | ->andReturn( 'wp_block' ); 333 | 334 | Functions\expect( 'get_taxonomies' ) 335 | ->with( [], 'objects' ) 336 | ->andReturn( [ 337 | $cat_taxonomy_obj, 338 | $fake_taxonomy_obj, 339 | ] ); 340 | 341 | 342 | Testee\admin_menu(); 343 | 344 | $expected_menu = [ 345 | 20 => [ 346 | 'Blocks', 347 | 'edit_wp_block', 348 | 'edit.php?post_type=wp_block', 349 | '', 350 | 'menu-top menu-icon-wp_block', 351 | 'menu-posts-wp_block', 352 | 'dashicons-fake-icon', 353 | ] 354 | ]; 355 | 356 | $expected_submenu = [ 357 | 'edit.php?post_type=wp_block' => [ 358 | 5 => [ 359 | 0 => 'All Blocks', 360 | 1 => 'edit_wp_block', 361 | 2 => 'edit.php?post_type=wp_block', 362 | ], 363 | 10 => [ 364 | 0 => 'Add New Block', 365 | 1 => 'create_wp_block', 366 | 2 => 'post-new.php?post_type=wp_block', 367 | ], 368 | 15 => [ 369 | 0 => 'Categories', 370 | 1 => 'manage_category', 371 | 2 => 'edit-tags.php?taxonomy=category&post_type=wp_block', 372 | ], 373 | ], 374 | ]; 375 | 376 | $this->assertSame( $expected_menu, $menu ); 377 | $this->assertSame( $expected_submenu, $submenu ); 378 | } 379 | 380 | /** 381 | * Tests `admin_menu` additions with Blocks menu. 382 | * 383 | * @return void 384 | */ 385 | public function test_admin_menu_custom_icon() { 386 | global $menu, $submenu, $_wp_last_object_menu; 387 | 388 | // phpcs:disable WordPress.WP.GlobalVariablesOverride.Prohibited 389 | $menu = []; 390 | 391 | $submenu = []; 392 | 393 | $_wp_last_object_menu = 19; 394 | // phpcs:enable WordPress.WP.GlobalVariablesOverride.Prohibited 395 | 396 | $post_type_obj = (object) [ 397 | 'cap' => (object) [ 398 | 'edit_posts' => 'edit_wp_block', 399 | 'create_posts' => 'create_wp_block', 400 | ], 401 | 'labels' => (object) [ 402 | 'menu_name' => 'Blocks', 403 | 'add_new' => 'Add New Block', 404 | 'all_items' => 'All Blocks', 405 | ], 406 | 'show_in_menu' => true, 407 | 'menu_position' => 59, 408 | 'menu_icon' => 'https://altis.local/icon.png', 409 | ]; 410 | 411 | $fake_taxonomy_obj = (object) [ 'show_ui' => false ]; 412 | 413 | $cat_taxonomy_obj = (object) [ 414 | 'cap' => (object) [ 415 | 'manage_terms' => 'manage_category', 416 | ], 417 | 'labels' => (object) [ 418 | 'menu_name' => 'Categories', 419 | ], 420 | 'name' => 'category', 421 | 'object_type' => [ 'post', 'wp_block' ], 422 | 'show_ui' => true, 423 | 'show_in_menu' => true, 424 | ]; 425 | 426 | Functions\stubEscapeFunctions(); 427 | 428 | Functions\expect( 'get_post_type_object' ) 429 | ->with( 'wp_block' ) 430 | ->andReturn( $post_type_obj ); 431 | 432 | Functions\expect( 'sanitize_html_class' ) 433 | ->with( 'wp_block' ) 434 | ->andReturn( 'wp_block' ); 435 | 436 | Functions\expect( 'get_taxonomies' ) 437 | ->with( [], 'objects' ) 438 | ->andReturn( [ 439 | $cat_taxonomy_obj, 440 | $fake_taxonomy_obj, 441 | ] ); 442 | 443 | 444 | Testee\admin_menu(); 445 | 446 | $expected_menu = [ 447 | 61 => [ 448 | 'Blocks', 449 | 'edit_wp_block', 450 | 'edit.php?post_type=wp_block', 451 | '', 452 | 'menu-top menu-icon-wp_block', 453 | 'menu-posts-wp_block', 454 | 'https://altis.local/icon.png', 455 | ] 456 | ]; 457 | 458 | $expected_submenu = [ 459 | 'edit.php?post_type=wp_block' => [ 460 | 5 => [ 461 | 0 => 'All Blocks', 462 | 1 => 'edit_wp_block', 463 | 2 => 'edit.php?post_type=wp_block', 464 | ], 465 | 10 => [ 466 | 0 => 'Add New Block', 467 | 1 => 'create_wp_block', 468 | 2 => 'post-new.php?post_type=wp_block', 469 | ], 470 | 15 => [ 471 | 0 => 'Categories', 472 | 1 => 'manage_category', 473 | 2 => 'edit-tags.php?taxonomy=category&post_type=wp_block', 474 | ], 475 | ], 476 | ]; 477 | 478 | $this->assertSame( $expected_menu, $menu ); 479 | $this->assertSame( $expected_submenu, $submenu ); 480 | } 481 | 482 | } 483 | -------------------------------------------------------------------------------- /tests/unit/rest-api/relationships/RESTEndpointTest.php: -------------------------------------------------------------------------------- 1 | testee = new Testee(); 17 | } 18 | 19 | public function test_register_rest_routes() { 20 | Functions\stubTranslationFunctions(); 21 | Functions\stubEscapeFunctions(); 22 | 23 | Functions\expect( 'register_rest_route' ) 24 | ->with( 25 | 'altis-reusable-blocks/v1', 26 | 'relationships', 27 | \Mockery::subset( 28 | [ 29 | [ 30 | 'methods' => 'GET', 31 | 'callback' => [ $this->testee, 'get_items' ], 32 | 'args' => [ 33 | 'context' => [ 34 | 'default' => 'view', 35 | ], 36 | 'block_id' => [ 37 | 'description' => 'Block ID to get the relationship data for.', 38 | 'required' => true, 39 | 'type' => 'integer', 40 | ], 41 | ], 42 | ], 43 | 'schema' => [ $this->testee, 'get_item_schema' ], 44 | ] 45 | ) 46 | ); 47 | 48 | $this->testee->register_routes(); 49 | } 50 | 51 | public function test_get_item_schema() { 52 | Functions\stubTranslationFunctions(); 53 | 54 | Functions\expect( 'get_post_stati' ) 55 | ->with( [ 'internal' => false ] ) 56 | ->andReturn( [ 57 | 'publish' => 'Publish', 58 | 'draft' => 'Draft', 59 | 'pending' => 'Pending', 60 | 'trash' => 'Trash', 61 | ] ); 62 | 63 | $schema = [ 64 | '$schema' => 'http://json-schema.org/draft-04/schema#', 65 | 'title' => 'Block relationships', 66 | 'type' => 'object', 67 | 'properties' => [ 68 | 'id' => [ 69 | 'description' => 'Unique identifier for the object.', 70 | 'type' => 'integer', 71 | 'context' => [ 'view' ], 72 | 'readonly' => true, 73 | ], 74 | 'status' => [ 75 | 'description' => 'A named status for the object.', 76 | 'type' => 'string', 77 | 'enum' => [ 78 | 'publish', 79 | 'draft', 80 | 'pending', 81 | 'trash', 82 | ], 83 | 'context' => [ 'view' ], 84 | 'readonly' => true, 85 | ], 86 | 'type' => [ 87 | 'description' => 'Type of Post for the object.', 88 | 'type' => 'string', 89 | 'context' => [ 'view' ], 90 | 'readonly' => true, 91 | ], 92 | 'title' => [ 93 | 'description' => 'The title for the object.', 94 | 'type' => 'object', 95 | 'context' => [ 'view' ], 96 | 'readonly' => true, 97 | 'properties' => [ 98 | 'rendered' => [ 99 | 'description' => 'HTML title for the object, transformed for display.', 100 | 'type' => 'string', 101 | 'context' => [ 'view' ], 102 | 'readonly' => true, 103 | ], 104 | ], 105 | ], 106 | ], 107 | ]; 108 | 109 | $this->assertSame( $schema, $this->testee->get_item_schema() ); 110 | } 111 | 112 | /** 113 | * Test if invalid response is passed into `prepare_response_for_collection`. 114 | */ 115 | public function test_prepare_response_for_collection_incorrect_response() { 116 | $expected = 'invalid'; 117 | 118 | $this->assertSame( $this->testee->prepare_response_for_collection( 'invalid' ), $expected ); 119 | } 120 | 121 | /** 122 | * Test if valid response is passed into `prepare_response_for_collection` and empty links returned from `WP_REST_Server::get_compact_response_links`. 123 | */ 124 | public function test_prepare_response_for_collection_correct_response_empty_links() { 125 | $data = [ 126 | [ 127 | 'ID' => 1, 128 | ] 129 | ]; 130 | 131 | $links = []; 132 | 133 | $response = \Mockery::mock( 'overload:' . \WP_REST_Response::class ); 134 | $response->shouldReceive( 'get_data' ) 135 | ->once() 136 | ->andReturn( $data ); 137 | 138 | $server = \Mockery::mock( 'overload:' . \WP_REST_Server::class ); 139 | $server->shouldReceive( 'get_compact_response_links' ) 140 | ->with( $response ) 141 | ->andReturn( $links ); 142 | 143 | Functions\expect( 'rest_get_server' ) 144 | ->once() 145 | ->andReturn( $server ); 146 | 147 | $this->assertSame( $data, $this->testee->prepare_response_for_collection( $response ) ); 148 | } 149 | 150 | /** 151 | * Test if valid response is passed into `prepare_response_for_collection` and two links returned from `WP_REST_Server::get_compact_response_links`. 152 | */ 153 | public function test_prepare_response_for_collection_correct_response_with_links() { 154 | $data = [ 155 | [ 156 | 'ID' => 1, 157 | ] 158 | ]; 159 | 160 | $links = [ 161 | 'prevLink' => 'https://altis.local/prevPage', 162 | 'nextLink' => 'https://altis.local/nextPage', 163 | ]; 164 | 165 | $expected_data = [ 166 | [ 167 | 'ID' => 1, 168 | ], 169 | '_links' => $links 170 | ]; 171 | 172 | $response = \Mockery::mock( 'overload:' . \WP_REST_Response::class ); 173 | $response->shouldReceive( 'get_data' ) 174 | ->once() 175 | ->andReturn( $data ); 176 | 177 | $server = \Mockery::mock( 'overload:' . \WP_REST_Server::class ); 178 | $server->shouldReceive( 'get_compact_response_links' ) 179 | ->with( $response ) 180 | ->andReturn( $links ); 181 | 182 | Functions\expect( 'rest_get_server' ) 183 | ->once() 184 | ->andReturn( $server ); 185 | 186 | $this->assertSame( $expected_data, $this->testee->prepare_response_for_collection( $response ) ); 187 | } 188 | 189 | /** 190 | * Test if error is returned when not supplying a block ID in `$request` when running `$testee->get_items()`. 191 | */ 192 | public function test_get_items_empty_block_id() { 193 | Functions\stubTranslationFunctions(); 194 | 195 | $request = \Mockery::mock( 'overload:' . \WP_REST_Request::class ); 196 | $request->shouldReceive( 'get_param' ) 197 | ->once() 198 | ->andReturn( null ); 199 | 200 | \Mockery::mock( 'overload:' . \WP_Error::class ); 201 | 202 | $this->assertTrue( is_a( $this->testee->get_items( $request ), 'WP_Error' ) ); 203 | } 204 | 205 | /** 206 | * Test if error is returned when supplying a block ID in `$request` but returning false from `get_post` when running `$testee->get_items()` 207 | */ 208 | public function test_get_items_no_post() { 209 | Functions\stubTranslationFunctions(); 210 | 211 | $request = \Mockery::mock( 'overload:' . \WP_REST_Request::class ); 212 | $request->shouldReceive( 'get_param' ) 213 | ->once() 214 | ->andReturn( 1 ); 215 | 216 | Functions\expect( 'get_post' ) 217 | ->with( 1 ) 218 | ->andReturn( null ); 219 | 220 | \Mockery::mock( 'overload:' . \WP_Error::class ); 221 | 222 | $this->assertTrue( is_a( $this->testee->get_items( $request ), 'WP_Error' ) ); 223 | } 224 | 225 | /** 226 | * Test if error is returned when supplying a block ID in `$request` but returning invalid post type from `get_post` when running `$testee->get_items()` 227 | */ 228 | public function test_get_items_invalid_post_type() { 229 | Functions\stubTranslationFunctions(); 230 | 231 | $request = \Mockery::mock( 'overload:' . \WP_REST_Request::class ); 232 | $request->shouldReceive( 'get_param' ) 233 | ->once() 234 | ->andReturn( 1 ); 235 | 236 | $post = \Mockery::mock( \WP_Post::class ); 237 | $post->post_type = 'post'; 238 | 239 | Functions\expect( 'get_post' ) 240 | ->with( 1 ) 241 | ->andReturn( $post ); 242 | 243 | \Mockery::mock( 'overload:' . \WP_Error::class ); 244 | 245 | $this->assertTrue( is_a( $this->testee->get_items( $request ), 'WP_Error' ) ); 246 | } 247 | 248 | /** 249 | * Test if empty array is returned when no associated term_id is returned when running `$testee->get_items()`. 250 | */ 251 | public function test_get_items_no_associated_term_id() { 252 | Functions\stubTranslationFunctions(); 253 | 254 | $request = \Mockery::mock( 'overload:' . \WP_REST_Request::class ); 255 | 256 | $request->shouldReceive( 'get_param' ) 257 | ->with( 'block_id' ) 258 | ->andReturn( 1 ); 259 | 260 | $request->shouldReceive( 'get_param' ) 261 | ->with( 'page' ) 262 | ->andReturn( 1 ); 263 | 264 | $post = \Mockery::mock( \WP_Post::class ); 265 | $post->post_type = 'wp_block'; 266 | 267 | Functions\expect( 'get_post' ) 268 | ->with( 1 ) 269 | ->andReturn( $post ); 270 | 271 | Functions\expect( 'get_post_meta' ) 272 | ->with( 1, 'shadow_term_id', true ) 273 | ->andReturn( false ); 274 | 275 | $this->assertSame( $this->testee->get_items( $request ), [] ); 276 | } 277 | 278 | /** 279 | * Test if empty array is returned when no total posts is returned from `WP_Query` when running `$testee->get_items()`. 280 | */ 281 | public function test_get_items_no_total_posts() { 282 | Functions\stubTranslationFunctions(); 283 | 284 | $request = \Mockery::mock( 'overload:' . \WP_REST_Request::class ); 285 | 286 | $request->shouldReceive( 'get_param' ) 287 | ->with( 'block_id' ) 288 | ->andReturn( 1 ); 289 | 290 | $request->shouldReceive( 'get_param' ) 291 | ->with( 'page' ) 292 | ->andReturn( 1 ); 293 | 294 | $post = \Mockery::mock( \WP_Post::class ); 295 | $post->post_type = 'wp_block'; 296 | 297 | Functions\expect( 'get_post' ) 298 | ->with( 1 ) 299 | ->andReturn( $post ); 300 | 301 | Functions\expect( 'get_post_meta' ) 302 | ->with( 1, 'shadow_term_id', true ) 303 | ->andReturn( 1 ); 304 | 305 | $query = \Mockery::mock( 'overload:' . \WP_Query::class ) 306 | ->shouldReceive( 'query' ) 307 | ->andSet( 'found_posts', 0 ) 308 | ->andReturn( [] ); 309 | 310 | $this->assertSame( $this->testee->get_items( $request ), [] ); 311 | } 312 | 313 | /** 314 | * Test if `WP_Error` is returned when invalid page is requested when running `$testee->get_items()`. 315 | */ 316 | public function test_get_items_invalid_page() { 317 | Functions\stubTranslationFunctions(); 318 | 319 | $request = \Mockery::mock( 'overload:' . \WP_REST_Request::class ); 320 | 321 | $request->shouldReceive( 'get_param' ) 322 | ->with( 'block_id' ) 323 | ->andReturn( 1 ); 324 | 325 | $request->shouldReceive( 'get_param' ) 326 | ->with( 'page' ) 327 | ->andReturn( 3 ); 328 | 329 | $post = \Mockery::mock( \WP_Post::class ); 330 | $post->post_type = 'wp_block'; 331 | 332 | Functions\expect( 'get_post' ) 333 | ->with( 1 ) 334 | ->andReturn( $post ); 335 | 336 | Functions\expect( 'get_post_meta' ) 337 | ->with( 1, 'shadow_term_id', true ) 338 | ->andReturn( 1 ); 339 | 340 | $query = \Mockery::mock( 'overload:' . \WP_Query::class ) 341 | ->shouldReceive( 'query' ) 342 | ->andSet( 'found_posts', 1 ) 343 | ->andReturn( [ 344 | [ 345 | 'ID' => 1 346 | ], 347 | ] ); 348 | 349 | \Mockery::mock( 'overload:' . \WP_Error::class ); 350 | 351 | $this->assertTrue( is_a( $this->testee->get_items( $request ), 'WP_Error' ) ); 352 | } 353 | 354 | /** 355 | * Test if `WP_Error` is returned when the response is an error, when running `$testee->get_items()`. 356 | */ 357 | public function test_get_items_response_error() { 358 | Functions\stubTranslationFunctions(); 359 | 360 | $request = \Mockery::mock( 'overload:' . \WP_REST_Request::class ); 361 | 362 | $request->shouldReceive( 'get_param' ) 363 | ->with( 'block_id' ) 364 | ->andReturn( 1 ); 365 | 366 | $request->shouldReceive( 'get_param' ) 367 | ->with( 'page' ) 368 | ->andReturn( 1 ); 369 | 370 | $post = \Mockery::mock( \WP_Post::class ); 371 | $post->post_type = 'wp_block'; 372 | 373 | Functions\expect( 'get_post' ) 374 | ->with( 1 ) 375 | ->andReturn( $post ); 376 | 377 | Functions\expect( 'get_post_meta' ) 378 | ->with( 1, 'shadow_term_id', true ) 379 | ->andReturn( 1 ); 380 | 381 | $post = \Mockery::mock( \WP_Post::class ); 382 | $post->ID = 1; 383 | $post->post_status = 'publish'; 384 | $post->post_title = 'Post title'; 385 | $post->post_type = 'post'; 386 | 387 | $query = \Mockery::mock( 'overload:' . \WP_Query::class ) 388 | ->shouldReceive( 'query' ) 389 | ->andSet( 'found_posts', 1 ) 390 | ->andReturn( [ $post ] ); 391 | 392 | $data = [ 393 | 'id' => 1, 394 | 'status' => 'publish', 395 | 'type' => 'post', 396 | 'title' => [ 397 | 'rendered' => 'Post title', 398 | ], 399 | ]; 400 | 401 | $links = []; 402 | 403 | Functions\expect( 'get_the_title' ) 404 | ->with( 1 ) 405 | ->andReturn( 'Post title' ); 406 | 407 | $response = \Mockery::mock( 'overload:' . \WP_REST_Response::class ); 408 | $response->shouldReceive( 'get_data' ) 409 | ->once() 410 | ->andReturn( $data ); 411 | 412 | $response->shouldReceive( 'is_error' ) 413 | ->andReturn( true ); 414 | 415 | $server = \Mockery::mock( 'overload:' . \WP_REST_Server::class ); 416 | $server->shouldReceive( 'get_compact_response_links' ) 417 | ->with( $response ) 418 | ->andReturn( $links ); 419 | 420 | Functions\expect( 'rest_get_server' ) 421 | ->once() 422 | ->andReturn( $server ); 423 | 424 | Functions\expect( 'rest_ensure_response' ) 425 | ->with( $data ) 426 | ->andReturn( $response ); 427 | 428 | \Mockery::mock( 'overload:' . \WP_Error::class ); 429 | 430 | $this->assertTrue( is_a( $this->testee->get_items( $request ), 'WP_Error' ) ); 431 | } 432 | 433 | /** 434 | * Test if valid response is returned when the first page is requested and there are no other pages, when running `$testee->get_items()`. 435 | */ 436 | public function test_get_items_valid() { 437 | Functions\stubTranslationFunctions(); 438 | 439 | $request = \Mockery::mock( 'overload:' . \WP_REST_Request::class ); 440 | 441 | $request->shouldReceive( 'get_param' ) 442 | ->with( 'block_id' ) 443 | ->andReturn( 1 ); 444 | 445 | $request->shouldReceive( 'get_param' ) 446 | ->with( 'page' ) 447 | ->andReturn( 1 ); 448 | 449 | $request->shouldReceive( 'get_query_params' ) 450 | ->andReturn( [] ); 451 | 452 | Functions\expect( 'add_query_arg' ) 453 | ->with( '' ) 454 | ->andReturn( '' ) 455 | ->once(); 456 | 457 | Functions\expect( 'urlencode_deep' ) 458 | ->with( [] ) 459 | ->andReturn( '' ) 460 | ->once(); 461 | 462 | Functions\expect( 'rest_url' ) 463 | ->with( 'altis-reusable-blocks/v1/relationships' ) 464 | ->once(); 465 | 466 | $post = \Mockery::mock( \WP_Post::class ); 467 | $post->post_type = 'wp_block'; 468 | 469 | Functions\expect( 'get_post' ) 470 | ->with( 1 ) 471 | ->andReturn( $post ); 472 | 473 | Functions\expect( 'get_post_meta' ) 474 | ->with( 1, 'shadow_term_id', true ) 475 | ->andReturn( 1 ); 476 | 477 | $post = \Mockery::mock( \WP_Post::class ); 478 | $post->ID = 1; 479 | $post->post_status = 'publish'; 480 | $post->post_title = 'Post title'; 481 | $post->post_type = 'post'; 482 | 483 | $query = \Mockery::mock( 'overload:' . \WP_Query::class ) 484 | ->shouldReceive( 'query' ) 485 | ->andSet( 'found_posts', 1 ) 486 | ->andReturn( [ $post ] ); 487 | 488 | $data = [ 489 | 'id' => 1, 490 | 'status' => 'publish', 491 | 'type' => 'post', 492 | 'title' => [ 493 | 'rendered' => 'Post title', 494 | ], 495 | ]; 496 | 497 | $links = []; 498 | 499 | Functions\expect( 'get_the_title' ) 500 | ->with( 1 ) 501 | ->andReturn( 'Post title' ); 502 | 503 | $response = \Mockery::mock( 'overload:' . \WP_REST_Response::class ); 504 | $response->shouldReceive( 'get_data' ) 505 | ->once() 506 | ->andReturn( $data ); 507 | 508 | $response->shouldReceive( 'is_error' ) 509 | ->andReturn( false ); 510 | 511 | $response->shouldReceive( 'header' ) 512 | ->with( \Mockery::anyOf( 'X-WP-Total', 'X-WP-TotalPages' ), 1 ) 513 | ->andReturn( true ); 514 | 515 | $server = \Mockery::mock( 'overload:' . \WP_REST_Server::class ); 516 | $server->shouldReceive( 'get_compact_response_links' ) 517 | ->with( $response ) 518 | ->andReturn( $links ); 519 | 520 | Functions\expect( 'rest_get_server' ) 521 | ->once() 522 | ->andReturn( $server ); 523 | 524 | Functions\expect( 'rest_ensure_response' ) 525 | ->with( $data ) 526 | ->andReturn( $response ); 527 | 528 | $this->testee->get_items( $request ); 529 | } 530 | 531 | /** 532 | * Test if valid response is returned when the first page is requested and there are no other pages, when running `$testee->get_items()`. 533 | */ 534 | public function test_get_items_valid_with_pagination() { 535 | Functions\stubTranslationFunctions(); 536 | 537 | $request = \Mockery::mock( 'overload:' . \WP_REST_Request::class ); 538 | 539 | $request->shouldReceive( 'get_param' ) 540 | ->with( 'block_id' ) 541 | ->andReturn( 1 ); 542 | 543 | $request->shouldReceive( 'get_param' ) 544 | ->with( 'page' ) 545 | ->andReturn( 2 ); 546 | 547 | $request->shouldReceive( 'get_query_params' ) 548 | ->andReturn( [] ); 549 | 550 | Functions\expect( 'add_query_arg' ) 551 | ->with( '' ) 552 | ->andReturn( '' ); 553 | 554 | Functions\expect( 'urlencode_deep' ) 555 | ->with( [] ) 556 | ->andReturn( '' ) 557 | ->once(); 558 | 559 | Functions\expect( 'rest_url' ) 560 | ->with( 'altis-reusable-blocks/v1/relationships' ) 561 | ->once(); 562 | 563 | $post = \Mockery::mock( \WP_Post::class ); 564 | $post->post_type = 'wp_block'; 565 | 566 | Functions\expect( 'get_post' ) 567 | ->with( 1 ) 568 | ->andReturn( $post ); 569 | 570 | Functions\expect( 'get_post_meta' ) 571 | ->with( 1, 'shadow_term_id', true ) 572 | ->andReturn( 1 ); 573 | 574 | $post = \Mockery::mock( \WP_Post::class ); 575 | $post->ID = 1; 576 | $post->post_status = 'publish'; 577 | $post->post_title = 'Post title'; 578 | $post->post_type = 'post'; 579 | 580 | $query = \Mockery::mock( 'overload:' . \WP_Query::class ) 581 | ->shouldReceive( 'query' ) 582 | ->andSet( 'found_posts', 32 ) 583 | ->andReturn( [ $post ] ); 584 | 585 | $data = [ 586 | 'id' => 1, 587 | 'status' => 'publish', 588 | 'type' => 'post', 589 | 'title' => [ 590 | 'rendered' => 'Post title', 591 | ], 592 | ]; 593 | 594 | $links = [ 595 | 'prevLink' => 'https://altis.local/prevPage', 596 | 'nextLink' => 'https://altis.local/nextPage', 597 | ]; 598 | 599 | Functions\expect( 'get_the_title' ) 600 | ->with( 1 ) 601 | ->andReturn( 'Post title' ); 602 | 603 | $response = \Mockery::mock( 'overload:' . \WP_REST_Response::class ); 604 | $response->shouldReceive( 'get_data' ) 605 | ->once() 606 | ->andReturn( $data ); 607 | 608 | $response->shouldReceive( 'is_error' ) 609 | ->andReturn( false ); 610 | 611 | $response->shouldReceive( 'header' ) 612 | ->with( \Mockery::anyOf( 'X-WP-Total', 'X-WP-TotalPages' ), \Mockery::any() ) 613 | ->andReturn( true ); 614 | 615 | $response->shouldReceive( 'link_header' ) 616 | ->with( \Mockery::anyOf( 'prev', 'next' ), \Mockery::any() ) 617 | ->andReturn( true ); 618 | 619 | $server = \Mockery::mock( 'overload:' . \WP_REST_Server::class ); 620 | $server->shouldReceive( 'get_compact_response_links' ) 621 | ->with( $response ) 622 | ->andReturn( $links ); 623 | 624 | Functions\expect( 'rest_get_server' ) 625 | ->once() 626 | ->andReturn( $server ); 627 | 628 | Functions\expect( 'rest_ensure_response' ) 629 | ->with( $data ) 630 | ->andReturn( $response ); 631 | 632 | $this->testee->get_items( $request ); 633 | } 634 | 635 | public function test_prepare_item_for_response() { 636 | $data = [ 637 | 'id' => 1, 638 | 'status' => 'publish', 639 | 'type' => 'post', 640 | 'title' => [ 641 | 'rendered' => 'Post title', 642 | ], 643 | ]; 644 | 645 | $post = \Mockery::mock( \WP_Post::class ); 646 | $post->ID = 1; 647 | $post->post_status = 'publish'; 648 | $post->post_title = 'Post title'; 649 | $post->post_type = 'post'; 650 | 651 | $request = \Mockery::mock( 'overload:' . \WP_REST_Request::class ); 652 | 653 | Functions\expect( 'get_the_title' ) 654 | ->with( 1 ) 655 | ->andReturn( 'Post title' ); 656 | 657 | $response = \Mockery::mock( \WP_REST_Response::class ); 658 | 659 | Functions\expect( 'rest_ensure_response' ) 660 | ->with( $data ) 661 | ->andReturn( $response ); 662 | 663 | $this->testee->prepare_item_for_response( $post, $request ); 664 | } 665 | } 666 | -------------------------------------------------------------------------------- /tests/unit/ConnectionsTest.php: -------------------------------------------------------------------------------- 1 | with( 'Altis\ReusableBlocks\Connections\register_relationship_taxonomy' ); 23 | 24 | Actions\expectAdded( 'wp_insert_post' ) 25 | ->with( 'Altis\ReusableBlocks\Connections\maybe_create_shadow_term', 10, 2 ); 26 | 27 | Actions\expectAdded( 'before_delete_post' ) 28 | ->with( 'Altis\ReusableBlocks\Connections\delete_shadow_term' ); 29 | 30 | Actions\expectAdded( 'post_updated' ) 31 | ->with( 'Altis\ReusableBlocks\Connections\synchronize_associated_terms', 10, 3 ); 32 | 33 | Filters\expectAdded( 'manage_wp_block_posts_columns' ) 34 | ->with( 'Altis\ReusableBlocks\Connections\manage_wp_block_posts_columns' ); 35 | 36 | Actions\expectAdded( 'manage_wp_block_posts_custom_column' ) 37 | ->with( 'Altis\ReusableBlocks\Connections\usage_column_output', 10, 2 ); 38 | 39 | Testee\bootstrap(); 40 | } 41 | 42 | /** 43 | * Tests `register_relationship_taxonomy` function. 44 | * 45 | * @return void 46 | */ 47 | public function test_register_relationship_taxonomy() { 48 | Functions\expect( 'register_taxonomy' ) 49 | ->with( 50 | Testee\RELATIONSHIP_TAXONOMY, 51 | Testee\POST_POST_TYPE, 52 | [ 53 | 'rewrite' => false, 54 | 'show_tagcloud' => false, 55 | 'hierarchical' => true, 56 | 'show_in_menu' => false, 57 | 'meta_box_cb' => false, 58 | 'public' => false, 59 | ] 60 | ) 61 | ->andReturn( true ); 62 | 63 | Testee\register_relationship_taxonomy(); 64 | } 65 | 66 | /** 67 | * Tests `maybe_create_shadow_term` and the scenario where the 68 | * post is the invalid post type and returns false. 69 | * 70 | * @return void 71 | */ 72 | public function test_maybe_create_shadow_term_invalid_post_type() { 73 | $post = \Mockery::mock( \WP_Post::class ); 74 | $post->post_type = Testee\POST_POST_TYPE; 75 | $post->post_status = 'publish'; 76 | 77 | $this->assertFalse( Testee\maybe_create_shadow_term( 1, $post ) ); 78 | } 79 | 80 | /** 81 | * Tests `maybe_create_shadow_term` and the scenario where the 82 | * post has a status of `auto-draft` and returns false. 83 | * 84 | * @return void 85 | */ 86 | public function test_maybe_create_shadow_term_auto_draft_post_status() { 87 | $post = \Mockery::mock( \WP_Post::class ); 88 | $post->post_type = BLOCK_POST_TYPE; 89 | $post->post_status = 'auto-draft'; 90 | 91 | $this->assertFalse( Testee\maybe_create_shadow_term( 1, $post ) ); 92 | } 93 | 94 | /** 95 | * Tests `maybe_create_shadow_term` and the scenario where the 96 | * shadow term does not exist and needs to be created. 97 | * 98 | * @return void 99 | */ 100 | public function test_maybe_create_shadow_term_not_exist() { 101 | $post_id = 1; 102 | 103 | $post = \Mockery::mock( \WP_Post::class ); 104 | $post->post_type = BLOCK_POST_TYPE; 105 | $post->post_title = 'Test Title'; 106 | $post->post_name = 'test-title'; 107 | $post->post_status = 'publish'; 108 | 109 | $shadow_term = [ 110 | 'term_id' => 1, 111 | 'taxonomy_term_id' => 1, 112 | ]; 113 | 114 | Functions\expect( 'get_post_meta' ) 115 | ->with( $post_id, 'shadow_term_id', true ) 116 | ->andReturn( 1 ); 117 | 118 | Functions\expect( 'get_term_by' ) 119 | ->with( 'id', 1, Testee\RELATIONSHIP_TAXONOMY ) 120 | ->andReturn( false ); 121 | 122 | Functions\expect( 'wp_insert_term' ) 123 | ->with( 124 | $post->post_title, 125 | Testee\RELATIONSHIP_TAXONOMY, 126 | [ 127 | 'slug' => $post->post_name 128 | ] 129 | ) 130 | ->andReturn( $shadow_term ); 131 | 132 | Functions\expect( 'is_wp_error' ) 133 | ->with( $shadow_term ) 134 | ->andReturn( false ); 135 | 136 | Functions\expect( 'update_term_meta' ) 137 | ->with( $shadow_term['term_id'], 'shadow_post_id', $post_id ) 138 | ->andReturn( true ); 139 | 140 | Functions\expect( 'update_post_meta' ) 141 | ->with( 1, 'shadow_term_id', $shadow_term['term_id'] ) 142 | ->andReturn( true ); 143 | 144 | // Test that the shadow term was created. 145 | $this->assertTrue( Testee\maybe_create_shadow_term( 1, $post ) ); 146 | } 147 | 148 | /** 149 | * Tests `maybe_create_shadow_term` and the scenario where the 150 | * shadow term is already in sync. 151 | * 152 | * @return void 153 | */ 154 | public function test_maybe_create_shadow_term_already_in_sync() { 155 | $post = \Mockery::mock( \WP_Post::class ); 156 | $post->post_type = BLOCK_POST_TYPE; 157 | $post->post_title = 'Test Title'; 158 | $post->post_name = 'test-title'; 159 | $post->post_status = 'publish'; 160 | 161 | $term = \Mockery::mock( \WP_Term::class ); 162 | $term->term_id = 1; 163 | $term->slug = 'test-title'; 164 | $term->name = 'Test Title'; 165 | 166 | Functions\expect( 'get_post_meta' ) 167 | ->with( 1, 'shadow_term_id', true ) 168 | ->andReturn( 1 ); 169 | 170 | Functions\expect( 'get_term_by' ) 171 | ->with( 'id', 1, Testee\RELATIONSHIP_TAXONOMY ) 172 | ->andReturn( $term ); 173 | 174 | $this->assertFalse( Testee\maybe_create_shadow_term( 1, $post ) ); 175 | } 176 | 177 | /** 178 | * Tests `maybe_create_shadow_term` and the scenario where the 179 | * shadow term is not in sync and needs to update. 180 | * 181 | * @return void 182 | */ 183 | public function test_maybe_create_shadow_term_not_in_sync() { 184 | $post_id = 1; 185 | 186 | $post = \Mockery::mock( \WP_Post::class ); 187 | $post->post_type = BLOCK_POST_TYPE; 188 | $post->post_title = 'Test Title'; 189 | $post->post_name = 'test-title'; 190 | $post->post_status = 'publish'; 191 | 192 | $term = \Mockery::mock( \WP_Term::class ); 193 | $term->term_id = 1; 194 | $term->slug = 'test-title-123'; 195 | $term->name = 'Test Title 123'; 196 | 197 | $shadow_term = [ 198 | 'term_id' => 1, 199 | 'taxonomy_term_id' => 1, 200 | ]; 201 | 202 | Functions\expect( 'get_post_meta' ) 203 | ->with( $post_id, 'shadow_term_id', true ) 204 | ->andReturn( 1 ); 205 | 206 | Functions\expect( 'get_term_by' ) 207 | ->with( 'id', 1, Testee\RELATIONSHIP_TAXONOMY ) 208 | ->andReturn( $term ); 209 | 210 | Functions\expect( 'wp_update_term' ) 211 | ->with( 212 | $term->term_id, 213 | Testee\RELATIONSHIP_TAXONOMY, 214 | [ 215 | 'name' => $post->post_title, 216 | 'slug' => $post->post_name, 217 | ] 218 | ) 219 | ->andReturn( $shadow_term ); 220 | 221 | // Test that the shadow term was created. 222 | $this->assertTrue( Testee\maybe_create_shadow_term( 1, $post ) ); 223 | } 224 | 225 | /** 226 | * Tests `create_shadow_taxonomy_term` and the scenario where the 227 | * shadow term insertion returns a WP_Error. 228 | * 229 | * @return void 230 | */ 231 | public function test_create_shadow_taxonomy_term_false_on_wp_error() { 232 | $post_id = 1; 233 | 234 | $post = \Mockery::mock( \WP_Post::class ); 235 | $post->post_type = BLOCK_POST_TYPE; 236 | $post->post_title = 'Test Title'; 237 | $post->post_name = 'test-title'; 238 | $post->post_status = 'publish'; 239 | 240 | $error = \Mockery::mock( \WP_Error::class ); 241 | 242 | Functions\expect( 'wp_insert_term' ) 243 | ->with( 244 | $post->post_title, 245 | Testee\RELATIONSHIP_TAXONOMY, 246 | [ 247 | 'slug' => $post->post_name 248 | ] 249 | ) 250 | ->andReturn( $error ); 251 | 252 | Functions\expect( 'is_wp_error' ) 253 | ->with( $error ) 254 | ->andReturn( true ); 255 | 256 | // Test that the function returns false when `wp_insert_term` returns a WP_Error. 257 | $this->assertFalse( Testee\create_shadow_taxonomy_term( 1, $post ) ); 258 | } 259 | 260 | /** 261 | * Tests `create_shadow_taxonomy_term` and the scenario where the 262 | * shadow term insertion returns a WP_Error. 263 | * 264 | * @return void 265 | */ 266 | public function test_create_shadow_taxonomy_term_valid() { 267 | $post_id = 1; 268 | 269 | $post = \Mockery::mock( \WP_Post::class ); 270 | $post->post_type = BLOCK_POST_TYPE; 271 | $post->post_title = 'Test Title'; 272 | $post->post_name = 'test-title'; 273 | $post->post_status = 'publish'; 274 | 275 | $term = \Mockery::mock( \WP_Term::class ); 276 | $term->term_id = 1; 277 | $term->slug = 'test-title-123'; 278 | $term->name = 'Test Title 123'; 279 | 280 | $shadow_term = [ 281 | 'term_id' => 1, 282 | 'taxonomy_term_id' => 1, 283 | ]; 284 | 285 | Functions\expect( 'wp_insert_term' ) 286 | ->with( 287 | $post->post_title, 288 | Testee\RELATIONSHIP_TAXONOMY, 289 | [ 290 | 'slug' => $post->post_name 291 | ] 292 | ) 293 | ->andReturn( $shadow_term ); 294 | 295 | Functions\expect( 'is_wp_error' ) 296 | ->with( $shadow_term ) 297 | ->andReturn( false ); 298 | 299 | Functions\expect( 'update_term_meta' ) 300 | ->with( $shadow_term['term_id'], 'shadow_post_id', $post_id ) 301 | ->andReturn( true ); 302 | 303 | Functions\expect( 'update_post_meta' ) 304 | ->with( 1, 'shadow_term_id', $shadow_term['term_id'] ) 305 | ->andReturn( true ); 306 | 307 | // Test that the shadow term was created. 308 | $this->assertTrue( Testee\create_shadow_taxonomy_term( 1, $post ) ); 309 | } 310 | 311 | /** 312 | * Test `delete_shadow_term` and the scenario where the post is not a valid post type. 313 | * 314 | * @return void 315 | */ 316 | public function test_delete_shadow_term_invalid_post_type() { 317 | $post_id = 1; 318 | 319 | Functions\expect( 'get_post_type' ) 320 | ->with( 1 ) 321 | ->andReturn( Testee\POST_POST_TYPE ); 322 | 323 | $this->assertFalse( Testee\delete_shadow_term( $post_id ) ); 324 | } 325 | 326 | /** 327 | * Test `delete_shadow_term` and the scenario where there is no associated term. 328 | * 329 | * @return void 330 | */ 331 | public function test_delete_shadow_term_no_associated_term() { 332 | $post_id = 1; 333 | 334 | Functions\expect( 'get_post_type' ) 335 | ->with( 1 ) 336 | ->andReturn( BLOCK_POST_TYPE ); 337 | 338 | Functions\expect( 'get_post_meta' ) 339 | ->with( $post_id, 'shadow_term_id', true ) 340 | ->andReturn( 1 ); 341 | 342 | Functions\expect( 'get_term_by' ) 343 | ->with( 'id', 1, Testee\RELATIONSHIP_TAXONOMY ) 344 | ->andReturn( false ); 345 | 346 | $this->assertFalse( Testee\delete_shadow_term( $post_id ) ); 347 | } 348 | 349 | /** 350 | * Test `delete_shadow_term` and the scenario where the term is fully deleted. 351 | * 352 | * @return void 353 | */ 354 | public function test_delete_shadow_term_deleted_term() { 355 | $post_id = 1; 356 | 357 | $term = \Mockery::mock( \WP_Term::class ); 358 | $term->term_id = 1; 359 | $term->slug = 'test-title'; 360 | $term->name = 'Test Title'; 361 | 362 | Functions\expect( 'get_post_type' ) 363 | ->with( 1 ) 364 | ->andReturn( BLOCK_POST_TYPE ); 365 | 366 | Functions\expect( 'get_post_meta' ) 367 | ->with( $post_id, 'shadow_term_id', true ) 368 | ->andReturn( 1 ); 369 | 370 | Functions\expect( 'get_term_by' ) 371 | ->with( 'id', 1, Testee\RELATIONSHIP_TAXONOMY ) 372 | ->andReturn( $term ); 373 | 374 | Functions\expect( 'wp_delete_term' ) 375 | ->with( $term->term_id, Testee\RELATIONSHIP_TAXONOMY ) 376 | ->andReturn( true ); 377 | 378 | $this->assertTrue( Testee\delete_shadow_term( $post_id ) ); 379 | } 380 | 381 | /** 382 | * Test `get_associated_post` and the scenario where the post requested is valid. 383 | * 384 | * @return void 385 | */ 386 | public function test_get_associated_post_valid_post() { 387 | $term_id = 1; 388 | 389 | $post = \Mockery::mock( \WP_Post::class ); 390 | $post->ID = 1; 391 | 392 | Functions\expect( 'get_term_meta' ) 393 | ->with( $term_id, 'shadow_post_id', true ) 394 | ->andReturn( $post->ID ); 395 | 396 | Functions\expect( 'get_post' ) 397 | ->with( $post->ID ) 398 | ->andReturn( $post ); 399 | 400 | $this->assertSame( Testee\get_associated_post( $term_id ), $post ); 401 | } 402 | 403 | /** 404 | * Test `shadow_term_in_sync` and the scenario where the term and post are synced. 405 | * 406 | * @return void 407 | */ 408 | public function test_shadow_term_in_sync_is_synced() { 409 | $post_id = 1; 410 | 411 | $post = \Mockery::mock( \WP_Post::class ); 412 | $post->post_title = 'Test Title'; 413 | $post->post_name = 'test-title'; 414 | 415 | $term = \Mockery::mock( \WP_Term::class ); 416 | $term->slug = 'test-title'; 417 | $term->name = 'Test Title'; 418 | 419 | $this->assertTrue( Testee\shadow_term_in_sync( $term, $post ) ); 420 | } 421 | 422 | /** 423 | * Test `shadow_term_in_sync` and the scenario where the term and post are not synced. 424 | * 425 | * @return void 426 | */ 427 | public function test_shadow_term_in_sync_not_synced() { 428 | $post_id = 1; 429 | 430 | $post = \Mockery::mock( \WP_Post::class ); 431 | $post->post_title = 'Test Title'; 432 | $post->post_name = 'test-title'; 433 | 434 | $term = \Mockery::mock( \WP_Term::class ); 435 | $term->slug = 'test-title-123'; 436 | $term->name = 'Test Title 123'; 437 | 438 | $this->assertFalse( Testee\shadow_term_in_sync( $term, $post ) ); 439 | } 440 | 441 | /** 442 | * Test `synchronize_associated_terms` and the scenario where the post is not a valid post type. 443 | * 444 | * @return void 445 | */ 446 | public function test_synchronize_associated_terms_invalid_post_type() { 447 | $post_id = 1; 448 | 449 | $post_before = \Mockery::mock( \WP_Post::class ); 450 | $post_before->post_type = 'invalid'; 451 | 452 | $post_after = \Mockery::mock( \WP_Post::class ); 453 | $post_after->post_type = 'invalid'; 454 | 455 | $this->assertFalse( Testee\synchronize_associated_terms( $post_id, $post_before, $post_after ) ); 456 | } 457 | 458 | /** 459 | * Test `synchronize_associated_terms` and the scenario where the post content before and after are the same. 460 | * 461 | * @return void 462 | */ 463 | public function test_synchronize_associated_terms_same_content() { 464 | $post_id = 1; 465 | 466 | $post_before = \Mockery::mock( \WP_Post::class ); 467 | $post_before->post_title = 'Test Title'; 468 | $post_before->post_name = 'test-title'; 469 | $post_before->post_content = 'Same content'; 470 | $post_before->post_type = BLOCK_POST_TYPE; 471 | 472 | $post_after = \Mockery::mock( \WP_Post::class ); 473 | $post_after->post_title = 'Test Title'; 474 | $post_after->post_name = 'test-title'; 475 | $post_after->post_content = 'Same content'; 476 | $post_after->post_type = BLOCK_POST_TYPE; 477 | 478 | $this->assertFalse( Testee\synchronize_associated_terms( $post_id, $post_before, $post_after ) ); 479 | } 480 | 481 | /** 482 | * Test `synchronize_associated_terms` and the scenario where the post content does not contain any reusable blocks. 483 | * 484 | * @return void 485 | */ 486 | public function test_synchronize_associated_terms_different_content_no_reusable_blocks() { 487 | $post_id = 1; 488 | 489 | $post_before = \Mockery::mock( \WP_Post::class ); 490 | $post_before->post_title = 'Test Title'; 491 | $post_before->post_name = 'test-title'; 492 | $post_before->post_content = 'Content'; 493 | $post_before->post_type = Testee\POST_POST_TYPE; 494 | 495 | $post_after = \Mockery::mock( \WP_Post::class ); 496 | $post_after->post_title = 'Test Title'; 497 | $post_after->post_name = 'test-title'; 498 | $post_after->post_content = 'Different content'; 499 | $post_after->post_type = Testee\POST_POST_TYPE; 500 | 501 | Functions\expect( 'parse_blocks' ) 502 | ->with( $post_after->post_content ) 503 | ->andReturn( [] ); 504 | 505 | Functions\expect( 'wp_set_object_terms' ) 506 | ->with( $post_id, null, Testee\RELATIONSHIP_TAXONOMY ) 507 | ->andReturn( [ 1, 1] ); 508 | 509 | $this->assertTrue( Testee\synchronize_associated_terms( $post_id, $post_before, $post_after ) ); 510 | } 511 | 512 | /** 513 | * Test `synchronize_associated_terms` and the scenario where the post content has reusable blocks within it. 514 | * 515 | * @return void 516 | */ 517 | public function test_synchronize_associated_terms_different_content_with_reusable_blocks() { 518 | $post_id = 1; 519 | 520 | $post_before = \Mockery::mock( \WP_Post::class ); 521 | $post_before->post_title = 'Test Title'; 522 | $post_before->post_name = 'test-title'; 523 | $post_before->post_content = 'Content'; 524 | $post_before->post_type = Testee\POST_POST_TYPE; 525 | 526 | $post_after = \Mockery::mock( \WP_Post::class ); 527 | $post_after->post_title = 'Test Title'; 528 | $post_after->post_name = 'test-title'; 529 | $post_after->post_content = 'Different content'; 530 | $post_after->post_type = Testee\POST_POST_TYPE; 531 | 532 | $parsed_blocks = [ 533 | [ 534 | 'blockName' => 'core/paragraph', 535 | 'attrs' => [], 536 | 'innerBlocks' => [], 537 | 'innerHTML' => 'This is a paragraph block!', 538 | 'innerContent' => [ 539 | 'This is a paragraph block!', 540 | ], 541 | ], 542 | [ 543 | 'blockName' => 'core/block', 544 | 'attrs' => [ 545 | 'ref' => 2, 546 | 'blockHtml' => 'This is a block content!', 547 | ], 548 | 'innerBlocks' => [], 549 | 'innerHTML' => 'This is a block content!', 550 | 'innerContent' => [ 551 | 'This is a block content!', 552 | ], 553 | ] 554 | ]; 555 | 556 | Functions\expect( 'parse_blocks' ) 557 | ->with( $post_after->post_content ) 558 | ->andReturn( $parsed_blocks ); 559 | 560 | Functions\expect( 'get_post_meta' ) 561 | ->with( $post_id, 'shadow_term_id', true ) 562 | ->andReturn( 1 ); 563 | 564 | Functions\expect( 'wp_cache_delete' ) 565 | ->with( sprintf( Testee\BLOCK_USAGE_COUNT_CACHE_KEY_FORMAT, 2 ) ) 566 | ->andReturn( true ); 567 | 568 | Functions\expect( 'wp_set_object_terms' ) 569 | ->with( $post_id, [ 1 ], Testee\RELATIONSHIP_TAXONOMY ) 570 | ->andReturn( [ 'term_id' => 1, 'taxonomy_term_id' => 1 ] ); 571 | 572 | $this->assertTrue( Testee\synchronize_associated_terms( $post_id, $post_before, $post_after ) ); 573 | } 574 | 575 | public function test_manage_wp_block_posts_columns() { 576 | Functions\stubTranslationFunctions(); 577 | 578 | $original_columns = [ 579 | 'title' => 'Title', 580 | 'date' => 'Date' 581 | ]; 582 | 583 | $expected_columns = [ 584 | 'title' => 'Title', 585 | 'usage-count' => 'Usage Count', 586 | 'date' => 'Date', 587 | ]; 588 | 589 | $this->assertSame( $expected_columns, Testee\manage_wp_block_posts_columns( $original_columns ) ); 590 | } 591 | 592 | public function test_usage_column_output_wrong_column() { 593 | ob_start(); 594 | Testee\usage_column_output( 'date', 1 ); 595 | $output = ob_get_clean(); 596 | 597 | $this->assertSame( '', $output ); 598 | } 599 | 600 | 601 | public function test_usage_column_output_cached_results() { 602 | Functions\stubEscapeFunctions(); 603 | 604 | Functions\expect( 'get_post_meta' ) 605 | ->with( 1, 'shadow_term_id', true ) 606 | ->andReturn( 1 ); 607 | 608 | Functions\expect( 'wp_cache_get' ) 609 | ->with( sprintf( Testee\BLOCK_USAGE_COUNT_CACHE_KEY_FORMAT, 1 ) ) 610 | ->andReturn( 42 ); 611 | 612 | Functions\expect( 'get_edit_post_link' ) 613 | ->with( 1 ) 614 | ->andReturn( 'http://altis.local/wp-admin/post.php?post=1&action=edit' ); 615 | 616 | $expected_output = '42'; 617 | 618 | ob_start(); 619 | Testee\usage_column_output( 'usage-count', 1 ); 620 | $output = ob_get_clean(); 621 | 622 | $this->assertSame( $expected_output, $output ); 623 | } 624 | 625 | public function test_usage_column_output_noncached_results() { 626 | Functions\stubEscapeFunctions(); 627 | 628 | Functions\expect( 'get_post_meta' ) 629 | ->with( 1, 'shadow_term_id', true ) 630 | ->andReturn( 1 ); 631 | 632 | Functions\expect( 'wp_cache_get' ) 633 | ->with( sprintf( Testee\BLOCK_USAGE_COUNT_CACHE_KEY_FORMAT, 1 ) ) 634 | ->andReturn( false ); 635 | 636 | $query = \Mockery::mock( 'overload:' . \WP_Query::class ) 637 | ->shouldReceive( 'query' ) 638 | ->andSet( 'post_count', 42 ) 639 | ->once() 640 | ->andReturn( [] ); 641 | 642 | Functions\expect( 'wp_cache_set' ) 643 | ->with( sprintf( Testee\BLOCK_USAGE_COUNT_CACHE_KEY_FORMAT, 1 ), '', 42, 8 * 60 * 60 ) 644 | ->andReturn( true ); 645 | 646 | Functions\expect( 'get_edit_post_link' ) 647 | ->with( 1 ) 648 | ->andReturn( 'http://altis.local/wp-admin/post.php?post=1&action=edit' ); 649 | 650 | $expected_output = '42'; 651 | 652 | ob_start(); 653 | Testee\usage_column_output( 'usage-count', 1 ); 654 | $output = ob_get_clean(); 655 | 656 | $this->assertSame( $expected_output, $output ); 657 | } 658 | 659 | public function test_usage_column_output_no_associated_term() { 660 | Functions\expect( 'get_post_meta' ) 661 | ->with( 1, 'shadow_term_id', true ) 662 | ->andReturn( false ); 663 | 664 | ob_start(); 665 | Testee\usage_column_output( 'usage-count', 1 ); 666 | $output = ob_get_clean(); 667 | 668 | $this->assertSame( '', $output ); 669 | } 670 | } 671 | --------------------------------------------------------------------------------