├── .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 |
142 | {
143 | sortedBlocks.map( ( block ) => (
144 | onItemSelect( block.id ) }
147 | onHover={ onHover }
148 | { ...block }
149 | />
150 | ) )
151 | }
152 |
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 | 
11 | 
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 |
--------------------------------------------------------------------------------