diff --git a/README.md b/README.md index 3558c906..bf6eb8f2 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,22 @@ A collection of components built to be used in the block editor. These component - [Repeater](./components/repeater/) - [RichTextCharacterLimit](./components/rich-text-character-limit) +### Post related Components + +These components read/write information from the global post object or a `PostContext`. + +- [PostAuthor](./components/post-author/) +- [PostCategoryList](./components/post-category-list/) +- [PostContext](./components/post-context/) +- [PostDate](./components/post-date) +- [PostExcerpt](./components/post-excerpt/) +- [PostFeaturedImage](./components/post-featured-image/) +- [PostPrimaryCategory](./components/post-primary-category/) +- [PostPrimaryTerm](./components/post-primary-term/) +- [PostTermList](./components/post-term-list/) +- [PostTitle](./components/post-title/) +- [PostMeta](./components/post-meta/) + ## Hooks - [useFilteredList](./hooks/use-filtered-list) @@ -46,6 +62,22 @@ A collection of components built to be used in the block editor. These component - [useRequestData](./hooks/use-request-data/) - [useBlockParentAttributes](./hooks/use-block-parent-attributes/) - [useScript](./hooks/use-script/) +- [useIsPluginActive](./hooks/use-is-plugin-active/) +- [usePopover](./hooks/use-popover/) + +### Post related hooks + +These hooks read/write information from the global post object or a `PostContext`. + +- [useAllTerms](./hooks/use-all-terms/) +- [useTaxonomy](./hooks/use-taxonomy/) +- [useIsSupportedTaxonomy](./hooks/use-is-supported-taxonomy/) +- [usePost](./hooks/use-post/) +- [usePrimaryTerm](./hooks/use-primary-term/) +- [useSelectedTermIds](./hooks/use-selected-term-ids/) +- [useSelectedTerms](./hooks/use-selected-terms/) +- [useSelectedTermsOfSavedPost](./hooks/use-selected-terms-of-saved-post/) +- [usePostMetaValue](./hooks/use-post-meta-value/) ## Stores diff --git a/api/register-icons/index.js b/api/register-icons/index.js index 3b2d8ed3..3200a327 100644 --- a/api/register-icons/index.js +++ b/api/register-icons/index.js @@ -1,7 +1,10 @@ import { dispatch } from '@wordpress/data'; +import domReady from '@wordpress/dom-ready'; import { iconStore } from '../../stores'; export function registerIcons(options) { - dispatch(iconStore).registerIconSet(options); + domReady(() => { + dispatch(iconStore).registerIconSet(options); + }); } diff --git a/components/author/context.js b/components/author/context.js new file mode 100644 index 00000000..a711fea6 --- /dev/null +++ b/components/author/context.js @@ -0,0 +1,3 @@ +import { createContext } from '@wordpress/element'; + +export const AuthorContext = createContext(); diff --git a/components/author/index.js b/components/author/index.js new file mode 100644 index 00000000..8fe39562 --- /dev/null +++ b/components/author/index.js @@ -0,0 +1,144 @@ +import { useContext } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; +import { store as blockEditorStore } from '@wordpress/block-editor'; +import PropTypes from 'prop-types'; +import { AuthorContext } from './context'; + +/** + * @typedef {object} Author + * @property {object} author + * @property {object} author.avatar_urls + * @property {string} author.description + * @property {string} author.email + * @property {string} author.first_name + * @property {number} author.id + * @property {string} author.last_name + * @property {string} author.link + * @property {string} author.name + * @property {string} author.nickname + * @property {string} author.registered_date + * @property {string} author.slug + * @property {string} author.url + */ + +export const Name = (props) => { + const { tagName: TagName, ...rest } = props; + + /** + * @type {Author} + */ + const { name, link } = useContext(AuthorContext); + + const wrapperProps = { ...rest }; + + if (TagName === 'a' && link) { + wrapperProps.href = link; + } + + return {name}; +}; + +Name.propTypes = { + tagName: PropTypes.string, +}; + +Name.defaultProps = { + tagName: 'span', +}; + +export const FirstName = (props) => { + const { tagName: TagName, ...rest } = props; + + /** + * @type {Author} + */ + const { first_name: firstName } = useContext(AuthorContext); + + return {firstName}; +}; + +FirstName.propTypes = { + tagName: PropTypes.string, +}; + +FirstName.defaultProps = { + tagName: 'span', +}; + +export const LastName = (props) => { + const { tagName: TagName, ...rest } = props; + + /** + * @type {Author} + */ + const { last_name: lastName } = useContext(AuthorContext); + + return {lastName}; +}; + +LastName.propTypes = { + tagName: PropTypes.string, +}; + +LastName.defaultProps = { + tagName: 'span', +}; + +function useDefaultAvatar() { + const { avatarURL: defaultAvatarUrl } = useSelect((select) => { + const { getSettings } = select(blockEditorStore); + const { __experimentalDiscussionSettings } = getSettings(); + return __experimentalDiscussionSettings; + }); + return defaultAvatarUrl; +} + +export const Avatar = (props) => { + const { ...rest } = props; + + /** + * @type {Author} + */ + const authorDetails = useContext(AuthorContext); + + const avatarUrls = authorDetails?.avatar_urls ? Object.values(authorDetails.avatar_urls) : null; + const defaultAvatar = useDefaultAvatar(); + + const avatarSourceUrl = avatarUrls ? avatarUrls[avatarUrls.length - 1] : defaultAvatar; + + return ; +}; + +export const Bio = (props) => { + const { tagName: TagName = 'p', ...rest } = props; + + /** + * @type {Author} + */ + const { description } = useContext(AuthorContext); + + return {description}; +}; + +Bio.propTypes = { + tagName: PropTypes.string, +}; + +Bio.defaultProps = { + tagName: 'p', +}; + +export const Email = (props) => { + const { ...rest } = props; + + /** + * @type {Author} + */ + const { email } = useContext(AuthorContext); + + return ( + + {email} + + ); +}; diff --git a/components/image/index.js b/components/image/index.js index 7affd219..14c8643c 100644 --- a/components/image/index.js +++ b/components/image/index.js @@ -1,5 +1,5 @@ import { MediaPlaceholder, InspectorControls } from '@wordpress/block-editor'; -import { Spinner, FocalPointPicker, PanelBody } from '@wordpress/components'; +import { Spinner, FocalPointPicker, PanelBody, Placeholder } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import PropTypes from 'prop-types'; @@ -12,6 +12,7 @@ const Image = (props) => { onSelect, focalPoint = { x: 0.5, y: 0.5 }, onChangeFocalPoint, + canEditImage = true, ...rest } = props; const hasImage = !!id; @@ -19,7 +20,11 @@ const Image = (props) => { const shouldDisplayFocalPointPicker = typeof onChangeFocalPoint === 'function'; - if (!hasImage) { + if (!hasImage && !canEditImage) { + return ; + } + + if (!hasImage && canEditImage) { return ; } @@ -67,6 +72,7 @@ Image.defaultProps = { size: 'large', focalPoint: { x: 0.5, y: 0.5 }, onChangeFocalPoint: undefined, + canEditImage: true, }; Image.propTypes = { @@ -78,4 +84,5 @@ Image.propTypes = { x: PropTypes.string, y: PropTypes.string, }), + canEditImage: PropTypes.bool, }; diff --git a/components/image/readme.md b/components/image/readme.md index ffdbadba..5a66ac95 100644 --- a/components/image/readme.md +++ b/components/image/readme.md @@ -46,4 +46,5 @@ function BlockEdit(props) { | `size` | `string` | `large` | Name of the image size to be displayed | | `focalPoint` | `object` | `{x:0.5,y:0.5}` | Optional focal point object. | `onChangeFocalPoint` | `function` | `undefined` | Callback that gets called with the new focal point when it changes. (Is required for the FocalPointPicker to appear) | -| `...rest` | `*` | `null` | Any additional attributes you want to pass to the underlying `img` tag | +| `...rest` | `*` | `null` | any additional attributes you want to pass to the underlying `img` tag | +| `canEditImage` | `boolean` | `true` | whether or not the image can be edited by in the context its getting viewed. Controls whether a placeholder or upload controls should be shown when no image is present | diff --git a/components/index.js b/components/index.js index bc62984d..fa74a066 100644 --- a/components/index.js +++ b/components/index.js @@ -12,5 +12,16 @@ export { Repeater } from './repeater'; export { Link } from './link'; export { MediaToolbar } from './media-toolbar'; export { Image } from './image'; +export { PostContext } from './post-context'; +export { PostTitle } from './post-title'; +export { PostFeaturedImage } from './post-featured-image'; +export { PostMeta } from './post-meta'; +export { PostExcerpt } from './post-excerpt'; +export { PostAuthor } from './post-author'; +export { PostDate, PostDatePicker } from './post-date'; +export { PostTermList } from './post-term-list'; +export { PostCategoryList } from './post-category-list'; +export { PostPrimaryTerm } from './post-primary-term'; +export { PostPrimaryCategory } from './post-primary-category'; export { RichTextCharacterLimit, getCharacterCount } from './rich-text-character-limit'; export { CircularProgressBar, Counter } from './counter'; diff --git a/components/post-author/index.js b/components/post-author/index.js new file mode 100644 index 00000000..d8a60bb8 --- /dev/null +++ b/components/post-author/index.js @@ -0,0 +1,74 @@ +import { Children } from '@wordpress/element'; +import { store as coreStore } from '@wordpress/core-data'; +import { Spinner } from '@wordpress/components'; +import { useSelect } from '@wordpress/data'; +import PropTypes from 'prop-types'; +import { usePost } from '../../hooks'; +import { Name, FirstName, LastName, Avatar, Bio, Email } from '../author'; + +import { AuthorContext } from '../author/context'; + +export const PostAuthor = (props) => { + const { children, ...rest } = props; + const { postId, postType } = usePost(); + + const [author, hasResolved] = useSelect( + (select) => { + const { getEditedEntityRecord, getUser, hasFinishedResolution } = select(coreStore); + + const postQuery = ['postType', postType, postId]; + + const post = getEditedEntityRecord(...postQuery); + const hasResolvedPost = hasFinishedResolution('getEditedEntityRecord', postQuery); + + const _authorId = hasResolvedPost ? post?.author : undefined; + + const author = getUser(_authorId); + const hasResolvedAuthor = hasFinishedResolution('getUser', [_authorId]); + + return [author, hasResolvedAuthor && hasResolvedPost]; + }, + [postType, postId], + ); + + const hasRenderCallback = typeof children === 'function'; + + const hasChildComponents = !hasRenderCallback && Children.count(children); + + if (!hasResolved) { + return ; + } + + if (hasChildComponents) { + return ( + +
{children}
+
+ ); + } + + if (hasRenderCallback) { + return children(author); + } + + return ; +}; + +PostAuthor.propTypes = { + children: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.node, + PropTypes.arrayOf(PropTypes.node), + ]), +}; + +PostAuthor.defaultProps = { + children: null, +}; + +PostAuthor.Name = Name; +PostAuthor.FirstName = FirstName; +PostAuthor.LastName = LastName; +PostAuthor.Avatar = Avatar; +PostAuthor.Bio = Bio; +PostAuthor.Email = Email; diff --git a/components/post-author/readme.md b/components/post-author/readme.md new file mode 100644 index 00000000..3f2f72c8 --- /dev/null +++ b/components/post-author/readme.md @@ -0,0 +1,103 @@ +# PostAuthor + +The `PostAuthor` component is part of the suite of components that read / write data from the global post object or the current [``](../post-context/). It has a composable declarative approach to allow you full control over the markup without needing to worry about the data handling at all. + +The Component allows you to showcase information about the author of the current post. + +## Usage + +```js +import { PostAuthor } from '@10up/block-components'; + +function BlockEdit() { + + return ( + + + + + + + + ) +} +``` + +## Props + +| Name | Type | Default | Description | +| ---------- | ----------------- | -------- | -------------------------------------------------------------- | +| `children` | `function\|node\|null` | `null` | | +| `...rest` | `object` | `{}` | | + +## Child Components + +You can use this component in three different ways. Without any children it will just return some default markup containing the name of the author. Alternatively you can pass a render function as the children. This render function will get the author object passed into it. The third option is using the provided sub-components as shown in the example code. Each of these child component again automatically manages the data and allows you to just focus on the markup. + +## Sub-Components + +These are all the available sub-components of the `PostAuthor` component. + +### `PostAuthor.Name` + +Returns the full name of the Author + +#### Props + +| Name | Type | Default | Description | +| ---------- | ----------------- | -------- | -------------------------------------------------------------- | +| `tagName` | `string` | `span` | the tag name that should be used for the element | +| `...rest` | `object` | `{}` | | + +### `PostAuthor.FirstName` + +Returns the first name of the Author + +#### Props + +| Name | Type | Default | Description | +| ---------- | ----------------- | -------- | -------------------------------------------------------------- | +| `tagName` | `string` | `span` | the tag name that should be used for the element | +| `...rest` | `object` | `{}` | | + +### `PostAuthor.LastName` + +Returns the last name of the Author + +#### Props + +| Name | Type | Default | Description | +| ---------- | ----------------- | -------- | -------------------------------------------------------------- | +| `tagName` | `string` | `span` | the tag name that should be used for the element | +| `...rest` | `object` | `{}` | | + +### `PostAuthor.Avatar` + +Returns the avatar image of the Author + +#### Props + +| Name | Type | Default | Description | +| ---------- | ----------------- | -------- | -------------------------------------------------------------- | +| `...rest` | `object` | `{}` | | + +### `PostAuthor.Bio` + +Returns the bio of the Author + +#### Props + +| Name | Type | Default | Description | +| ---------- | ----------------- | -------- | -------------------------------------------------------------- | +| `tagName` | `string` | `p` | the tag name that should be used for the element | +| `...rest` | `object` | `{}` | | + +### `PostAuthor.Email` + +Returns the email of the Author + +#### Props + +| Name | Type | Default | Description | +| ---------- | ----------------- | -------- | -------------------------------------------------------------- | +| `...rest` | `object` | `{}` | | diff --git a/components/post-category-list/index.js b/components/post-category-list/index.js new file mode 100644 index 00000000..7f50b3e5 --- /dev/null +++ b/components/post-category-list/index.js @@ -0,0 +1,12 @@ +import PropTypes from 'prop-types'; +import { PostTermList } from '../post-term-list'; + +export const PostCategoryList = PostTermList; + +PostCategoryList.propTypes = { + taxonomyName: PropTypes.string, +}; + +PostCategoryList.defaultProps = { + taxonomyName: 'category', +}; diff --git a/components/post-category-list/readme.md b/components/post-category-list/readme.md new file mode 100644 index 00000000..e0485076 --- /dev/null +++ b/components/post-category-list/readme.md @@ -0,0 +1,70 @@ +# `PostCategoryList` + +The `PostCategoryList` component is part of the suite of components that read / write data from the global post object or the current [``](../post-context/). It has a composable declarative approach to allow you full control over the markup without needing to worry about the data handling at all. + +The Component allows you to showcase the list of Categories assigned to the current post + +## Usage + +```js +import { PostCategoryList } from '@10up/block-components'; + +function BlockEdit() { + + return ( + + + + + + ) +} +``` + +### Output + +```html + +``` + +## Props + +| Name | Type | Default | Description | +| ---------- | ----------------- | -------- | -------------------------------------------------------------- | +| `context` | `object` | `{}` | | +| `children` | `function\|node\|null` | `null` | | +| `tagName` | `string` | `ul` | | +| `...rest` | `object` | `{}` | | + +## Child Components + +You can use this component in three different ways. Without any children it will just return some default markup containing the name of the category. Alternatively you can pass a render function as the children. This render function will get the category object passed into it. The third option is using the provided sub-components as shown in the example code. Each of these child component again automatically manages the data and allows you to just focus on the markup. + +## Sub-Components + +These are all the available sub-components of the `PostCategoryList` component. + +### `PostCategoryList.ListItem` + +Returns the list item markup + +#### Props + +| Name | Type | Default | Description | +| ---------- | ----------------- | -------- | -------------------------------------------------------------- | +| `tagName` | `string` | `li` | the tag name that should be used for the element | +| `...rest` | `object` | `{}` | | + +### `PostCategoryList.TermLink` + +Returns the anchor element containing the title of the term + +#### Props + +| Name | Type | Default | Description | +| ---------- | ----------------- | -------- | -------------------------------------------------------------- | +| `...rest` | `object` | `{}` | | diff --git a/components/post-context/context.js b/components/post-context/context.js new file mode 100644 index 00000000..d87d63de --- /dev/null +++ b/components/post-context/context.js @@ -0,0 +1,13 @@ +import { createContext, useContext } from '@wordpress/element'; + +export const DEFAULT_POST_CONTEXT = { + postId: null, + postType: null, + isEditable: null, +}; + +export const PostContext = createContext(DEFAULT_POST_CONTEXT); + +export const usePostContext = () => { + return useContext(PostContext); +}; diff --git a/components/post-context/index.js b/components/post-context/index.js new file mode 100644 index 00000000..e0257493 --- /dev/null +++ b/components/post-context/index.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types'; +import { useMemo } from '@wordpress/element'; +import { DEFAULT_POST_CONTEXT, PostContext as PostContextContext } from './context'; + +export const PostContext = (props) => { + const { children, postId, postType, isEditable } = props; + + const value = useMemo( + () => ({ + postId, + postType, + isEditable, + }), + [postId, postType, isEditable], + ); + + return {children}; +}; + +PostContext.propTypes = { + children: PropTypes.node.isRequired, + postId: PropTypes.number, + postType: PropTypes.string, + isEditable: PropTypes.bool, +}; + +PostContext.defaultProps = DEFAULT_POST_CONTEXT; diff --git a/components/post-context/readme.md b/components/post-context/readme.md new file mode 100644 index 00000000..77f29d1b --- /dev/null +++ b/components/post-context/readme.md @@ -0,0 +1,53 @@ +# `PostContext` + +The `PostContext` component allows you to customize the post object referenced by any of the components referencing the current post object. They are all prefixed with `Post`. + +For example this can be used to build a custom block that gets used inside the core query loop and accesses the passed in post id / post type of that to power the functionality of all the `Post` child components. + +## Usage + +```js +import { PostContext, PostTitle } from '@10up/block-components'; + +function BlockEdit() { + + return ( + + + + ) +} +``` + +The `PostContext` component works great with the Core Query Loop block if you want / need to create a custom block to be used within the post template. Any block that gets used inside the query loop can access the current post id, post type, and query id via the block context. A block only needs to set the [`usesContext` property in the `block.json`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-metadata/#context) file to `[ "postId", "postType" ]`. These values can then be accessed via the `context` property passed into the block via the `props` and then used within the `` to make them available for any of the post level components nested within. + +```js +import { PostContext, PostTitle } from '@10up/block-components'; + +function BlockEdit(props) { + const { context } = props; + const { postId, postType, queryId } = context; + const isDescendantOfQueryLoop = Number.isFinite(queryId); + + return ( + + + + ) +} +``` + +*Note: If you enable `isEditable` prop in the `` component for posts that aren't the current post this will create updates the external post. These updates only get applied when the user clicks on the "Save" button at which point they will get shown a list of the different posts they are updating. + +This should be used with caution since it may not be immediately obvious for users.* + +![Block Editor Save button showing an indicator for unsaved external changes](../../images/block-editor-unsaved-external-change.png) + +## Props + +| Name | Type | Default | Description | +| ---------- | ----------------- | -------- | -------------------------------------------------------------- | +| `postId` | `number` | `null` | ID of the post | +| `postType` | `string` | `null` | post type of the post | +| `isEditable` | `boolean` | `null` | whether the post is editable. Controls the behavior of the nested `Post` components | +| `children` | `node` | `null` | any child components | diff --git a/components/post-date/index.js b/components/post-date/index.js new file mode 100644 index 00000000..90bf678a --- /dev/null +++ b/components/post-date/index.js @@ -0,0 +1,82 @@ +import { __ } from '@wordpress/i18n'; +import { DateTimePicker } from '@wordpress/components'; +import { getSettings, dateI18n } from '@wordpress/date'; +import { useEntityProp } from '@wordpress/core-data'; +import PropTypes from 'prop-types'; +import { usePopover } from '../../hooks/use-popover'; +import { usePost } from '../../hooks'; + +export const PostDatePicker = ({ date, setDate }) => { + const settings = getSettings(); + // To know if the current time format is a 12 hour time, look for "a". + // Also make sure this "a" is not escaped by a "/". + const is12Hour = /a(?!\\)/i.test( + settings.formats.time + .toLowerCase() // Test only for the lower case "a". + .replace(/\\\\/g, '') // Replace "//" with empty strings. + .split('') + .reverse() + .join(''), // Reverse the string and test for "a" not followed by a slash. + ); + + return ; +}; + +PostDatePicker.propTypes = { + date: PropTypes.string.isRequired, + setDate: PropTypes.func.isRequired, +}; + +export const PostDate = (props) => { + const { placeholder, format, ...rest } = props; + + const { postId, postType, isEditable } = usePost(); + + const [date, setDate] = useEntityProp('postType', postType, 'date', postId); + const [siteFormat] = useEntityProp('root', 'site', 'date_format'); + const settings = getSettings(); + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + + const resolvedFormat = format || siteFormat || settings.formats.date; + + const { toggleProps, Popover } = usePopover(); + + const timeString = dateI18n(resolvedFormat, date, timezone) || placeholder; + + let parentProps = { ...rest }; + + if (isEditable) { + parentProps = { + ...toggleProps, + ...parentProps, + }; + } + + return ( + <> + + {isEditable && ( + + + + )} + + ); +}; + +PostDate.propTypes = { + placeholder: PropTypes.string, + format: PropTypes.string, +}; + +PostDate.defaultProps = { + placeholder: __('No date set', 'tenup'), + format: undefined, +}; diff --git a/components/post-date/readme.md b/components/post-date/readme.md new file mode 100644 index 00000000..accf1d71 --- /dev/null +++ b/components/post-date/readme.md @@ -0,0 +1,26 @@ +# Post Date + +The `PostDate` component is part of the suite of components that read/write data from the global post object or the current [``](../post-context/). It has a composable declarative approach to allow you full control over the markup without needing to worry about the data handling at all. + +The Component allows you to showcase the publish date of the current post. + +## Usage + +```js +import { PostDate } from '@10up/block-components'; + +function BlockEdit() { + + return ( + + ) +} +``` + +## Props + +| Name | Type | Default | Description | +| ---------- | ----------------- | -------- | -------------------------------------------------------------- | +| `placeholder` | `string` | `No date set` | | +| `format` | `string` | | Uses the WordPress date format setting of the site | +| `...rest` | `object` | `{}` | | diff --git a/components/post-excerpt/index.js b/components/post-excerpt/index.js new file mode 100644 index 00000000..321fbe6e --- /dev/null +++ b/components/post-excerpt/index.js @@ -0,0 +1,41 @@ +import { useEntityProp } from '@wordpress/core-data'; +import { __ } from '@wordpress/i18n'; +import { RichText } from '@wordpress/block-editor'; +import PropTypes from 'prop-types'; +import { usePost } from '../../hooks'; + +export const PostExcerpt = (props) => { + const { placeholder, ...rest } = props; + const { postId, postType, isEditable } = usePost(); + + const [rawExcerpt = '', setExcerpt, fullExcerpt] = useEntityProp( + 'postType', + postType, + 'excerpt', + postId, + ); + + if (!isEditable) { + // eslint-disable-next-line react/no-danger + return

; + } + + return ( + + ); +}; + +PostExcerpt.propTypes = { + placeholder: PropTypes.string, +}; + +PostExcerpt.defaultProps = { + placeholder: __('Enter excerpt...', 'tenup'), +}; diff --git a/components/post-excerpt/readme.md b/components/post-excerpt/readme.md new file mode 100644 index 00000000..388779c4 --- /dev/null +++ b/components/post-excerpt/readme.md @@ -0,0 +1,25 @@ +# Post Excerpt + +The `PostExcerpt` component is part of the suite of components that read/write data from the global post object or the current [``](../post-context/). It has a composable declarative approach to allow you full control over the markup without needing to worry about the data handling at all. + +The Component allows you to showcase the excerpt of the current post. + +## Usage + +```js +import { PostExcerpt } from '@10up/block-components'; + +function BlockEdit() { + + return ( + + ) +} +``` + +## Props + +| Name | Type | Default | Description | +| ---------- | ----------------- | -------- | -------------------------------------------------------------- | +| `placeholder` | `string` | `Enter excerpt...` | | +| `...rest` | `object` | `{}` | | diff --git a/components/post-featured-image/index.js b/components/post-featured-image/index.js new file mode 100644 index 00000000..4d4d3bc5 --- /dev/null +++ b/components/post-featured-image/index.js @@ -0,0 +1,26 @@ +import { useEntityProp } from '@wordpress/core-data'; +import { usePost } from '../../hooks'; +import { Image } from '../image'; + +export const PostFeaturedImage = (props) => { + const { postId, postType, isEditable } = usePost(); + const [featuredImage, setFeaturedImage] = useEntityProp( + 'postType', + postType, + 'featured_media', + postId, + ); + + const handleImageSelect = (image) => { + setFeaturedImage(image.id); + }; + + return ( + + ); +}; diff --git a/components/post-featured-image/readme.md b/components/post-featured-image/readme.md new file mode 100644 index 00000000..65a55611 --- /dev/null +++ b/components/post-featured-image/readme.md @@ -0,0 +1,24 @@ +# Post Featured Image + +The `PostFeaturedImage` component is part of the suite of components that read/write data from the global post object or the current [``](../post-context/). It has a composable declarative approach to allow you full control over the markup without needing to worry about the data handling at all. + +The Component allows you to showcase the featured image of the current post. It uses the [`Image`](../image/) component under the hood but handles all the selection/image management logic. + +## Usage + +```js +import { PostFeaturedImage } from '@10up/block-components'; + +function BlockEdit() { + + return ( + + ) +} +``` + +## Props + +| Name | Type | Default | Description | +| ---------- | ----------------- | -------- | -------------------------------------------------------------- | +| `...rest` | `object` | `{}` | | diff --git a/components/post-meta/index.js b/components/post-meta/index.js new file mode 100644 index 00000000..2c09b216 --- /dev/null +++ b/components/post-meta/index.js @@ -0,0 +1,71 @@ +import { RichText } from '@wordpress/block-editor'; +import { __experimentalNumberControl as NumberControl, ToggleControl } from '@wordpress/components'; +import PropTypes from 'prop-types'; +import { usePostMetaValue } from '../../hooks'; +import { toSentence } from './utilities'; + +export const PostMeta = (props) => { + const { metaKey, children } = props; + const [metaValue, setMetaValue] = usePostMetaValue(metaKey); + const metaValueType = typeof metaValue; + + if (typeof children === 'function') { + return children(metaValue, setMetaValue); + } + + if (metaValueType === 'number') { + return ; + } + + if (metaValueType === 'boolean') { + return ; + } + + return ; +}; + +PostMeta.propTypes = { + metaKey: PropTypes.string.isRequired, +}; + +const MetaString = (props) => { + const { metaKey, tagName } = props; + const [metaValue, setMetaValue] = usePostMetaValue(metaKey); + + return ; +}; + +MetaString.propTypes = { + metaKey: PropTypes.string.isRequired, + tagName: PropTypes.string, +}; + +MetaString.defaultProps = { + tagName: 'p', +}; + +const MetaNumber = (props) => { + const { metaKey } = props; + const [metaValue, setMetaValue] = usePostMetaValue(metaKey); + + return ; +}; + +MetaNumber.propTypes = { + metaKey: PropTypes.string.isRequired, +}; + +const MetaBoolean = (props) => { + const { metaKey } = props; + const [metaValue, setMetaValue] = usePostMetaValue(metaKey); + + return ; +}; + +MetaBoolean.propTypes = { + metaKey: PropTypes.string.isRequired, +}; + +PostMeta.String = MetaString; +PostMeta.Number = MetaNumber; +PostMeta.Boolean = MetaBoolean; diff --git a/components/post-meta/readme.md b/components/post-meta/readme.md new file mode 100644 index 00000000..a890f2ff --- /dev/null +++ b/components/post-meta/readme.md @@ -0,0 +1,95 @@ +# Post Meta + +The `PostMeta` component is part of the suite of components that read/write data from the global post object or the current [``](../post-context/). It has a composable declarative approach to allow you full control over the markup without needing to worry about the data handling at all. + +The Component allows you to showcase any piece of post-meta information of the current post. + +> **Warning** +> In order for the Meta Value to show up it needs to be registered properly and have `show_in_rest` set to `true`. + +## Usage + +```js +import { PostMeta } from '@10up/block-components'; + +function BlockEdit() { + + return ( + + ) +} +``` + +The component automatically tries to figure out the type of the meta value and dynamically displays either a text field, number control, or a toggle control. + +If you want to override this automatic type casting you can use the sub-components which expose the underlying type controls. + +> **Warning** +> Currently only `string`, `number`, `boolean` for `single` meta values are supported. + +You can also completely customize the UI of the meta field by passing a [render function as the children](https://reactpatterns.js.org/docs/function-as-child-component/) of the `PostMeta` component: + +```js +import { PostMeta } from '@10up/block-components'; + +function BlockEdit() { + + return ( + + {( price, setPrice ) => ( + <> + + + + )} + + ) +} +``` + +## Props + +| Name | Type | Default | Description | +| ---------- | ----------------- | -------- | -------------------------------------------------------------- | +| `metaKey` | `string` | `""` | name of the meta key | +| `...rest` | `object` | `{}` | | + +## Sub-components + +This component contains a few sub-components which relate to the various types a piece of meta can be represented as. + +### `PostMeta.String` + +Control string meta values + +#### Props + +| Name | Type | Default | Description | +| ---------- | ----------------- | -------- | -------------------------------------------------------------- | +| `metaKey` | `string` | `""` | name of the meta key | +| `tagName` | `string` | `p` | tagName to be used by the underlying RichText field | +| `...rest` | `object` | `{}` | | + +### `PostMeta.Number` + +Control number meta values + +#### Props + +| Name | Type | Default | Description | +| ---------- | ----------------- | -------- | -------------------------------------------------------------- | +| `metaKey` | `string` | `""` | name of the meta key | +| `label` | `string` | `""` | Label to be added to the NumberControl | +| `...rest` | `object` | `{}` | | + +### `PostMeta.Boolean` + +Control boolean meta values + +#### Props + +| Name | Type | Default | Description | +| ---------- | ----------------- | -------- | -------------------------------------------------------------- | +| `metaKey` | `string` | `""` | name of the meta key | +| `label` | `string` | `""` | Label to be added to the ToggleControl | +| `...rest` | `object` | `{}` | | diff --git a/components/post-meta/utilities.js b/components/post-meta/utilities.js new file mode 100644 index 00000000..6580c76d --- /dev/null +++ b/components/post-meta/utilities.js @@ -0,0 +1,45 @@ +// Checks whether character is Uppercase. +// Crude version. Checks only A-Z. +function isCaps(char) { + if (char.match(/[A-Z]/)) return true; + return false; +} + +// Checks whether character is digit. +function isDigit(char) { + if (char.match(/[0-9]/)) return true; + return false; +} + +export function toKebab(string) { + return string + .split('') + .map((letter, index) => { + const previousLetter = string[index - 1] || ''; + const currentLetter = letter; + + if (isDigit(currentLetter) && !isDigit(previousLetter)) { + return `-${currentLetter}`; + } + + if (!isCaps(currentLetter)) return currentLetter; + + if (previousLetter === '') { + return `${currentLetter.toLowerCase()}`; + } + + if (isCaps(previousLetter)) { + return `${currentLetter.toLowerCase()}`; + } + + return `-${currentLetter.toLowerCase()}`; + }) + .join('') + .trim() + .replace(/[-_\s]+/g, '-'); +} + +export function toSentence(string) { + const interim = toKebab(string).replace(/-/g, ' '); + return interim.slice(0, 1).toUpperCase() + interim.slice(1); +} diff --git a/components/post-primary-category/index.js b/components/post-primary-category/index.js new file mode 100644 index 00000000..32a24c81 --- /dev/null +++ b/components/post-primary-category/index.js @@ -0,0 +1,17 @@ +import { __ } from '@wordpress/i18n'; +import PropTypes from 'prop-types'; +import { PostPrimaryTerm } from '../post-primary-term'; + +export const PostPrimaryCategory = PostPrimaryTerm; + +PostPrimaryCategory.propTypes = { + placeholder: PropTypes.string, + taxonomyName: PropTypes.string, + isLink: PropTypes.bool, +}; + +PostPrimaryCategory.defaultProps = { + placeholder: __('Select a category', 'tenup'), + taxonomyName: 'category', + isLink: true, +}; diff --git a/components/post-primary-category/readme.md b/components/post-primary-category/readme.md new file mode 100644 index 00000000..86bb2405 --- /dev/null +++ b/components/post-primary-category/readme.md @@ -0,0 +1,8 @@ +# `PostPrimaryCategory` + +The `PostPrimaryCategory` component is part of the suite of components that read/write data from the global post object or the current [``](../post-context/). It has a composable declarative approach to allow you full control over the markup without needing to worry about the data handling at all. + +The Component allows you to showcase the primary category of the current post. It relies on the Primary Term feature provided by the Yoast SEO plugin. + +> **Warning** +> This Component depends on the Primary Term functionality of the Yoast SEO Plugin diff --git a/components/post-primary-term/index.js b/components/post-primary-term/index.js new file mode 100644 index 00000000..d1f10a2c --- /dev/null +++ b/components/post-primary-term/index.js @@ -0,0 +1,43 @@ +import { __ } from '@wordpress/i18n'; +import PropTypes from 'prop-types'; +import { usePrimaryTerm } from '../../hooks'; + +export const PostPrimaryTerm = (props) => { + const { taxonomyName, placeholder, isLink, ...rest } = props; + + const [primaryTerm, isSupportedTaxonomy] = usePrimaryTerm(taxonomyName); + + const hasPrimaryTerm = !!primaryTerm; + + const termString = hasPrimaryTerm ? primaryTerm.name : placeholder; + const termUrl = hasPrimaryTerm ? primaryTerm.link : '#'; + + if (!isSupportedTaxonomy) { + return null; + } + + const Tag = isLink ? 'a' : 'span'; + + const wrapperProps = { + ...rest, + }; + + if (isLink) { + wrapperProps.href = termUrl; + wrapperProps.inert = 'true'; + } + + return {termString}; +}; + +PostPrimaryTerm.propTypes = { + placeholder: PropTypes.string, + taxonomyName: PropTypes.string, + isLink: PropTypes.bool, +}; + +PostPrimaryTerm.defaultProps = { + placeholder: __('Select a Term', 'tenup'), + isLink: true, + taxonomyName: 'category', +}; diff --git a/components/post-primary-term/readme.md b/components/post-primary-term/readme.md new file mode 100644 index 00000000..9fd75563 --- /dev/null +++ b/components/post-primary-term/readme.md @@ -0,0 +1,8 @@ +# PostPrimaryTerm + +The `PostPrimaryTerm` component is part of the suite of components that read/write data from the global post object or the current [``](../post-context/). It has a composable declarative approach to allow you full control over the markup without needing to worry about the data handling at all. + +The Component allows you to showcase the primary term of any taxonomy of the current post. + +> **Warning** +> This Component depends on the Primary Term functionality of the Yoast SEO Plugin diff --git a/components/post-term-list/context.js b/components/post-term-list/context.js new file mode 100644 index 00000000..70e27911 --- /dev/null +++ b/components/post-term-list/context.js @@ -0,0 +1,3 @@ +import { createContext } from '@wordpress/element'; + +export const PostTermContext = createContext(); diff --git a/components/post-term-list/index.js b/components/post-term-list/index.js new file mode 100644 index 00000000..ba024dee --- /dev/null +++ b/components/post-term-list/index.js @@ -0,0 +1,99 @@ +import { Spinner } from '@wordpress/components'; +import { Children } from '@wordpress/element'; +import PropTypes from 'prop-types'; +import { + PostTaxonomiesHierarchicalTermSelector, + PostTaxonomiesFlatTermSelector, +} from '@wordpress/editor'; + +import { usePopover, usePost, useSelectedTerms, useTaxonomy } from '../../hooks'; +import { PostTermContext } from './context'; +import { ListItem, TermLink } from './item'; + +export const PostTermList = (props) => { + const { tagName: TagName, taxonomyName, children, ...rest } = props; + + const { isEditable } = usePost(); + + const hasRenderCallback = typeof children === 'function'; + const hasChildComponents = !hasRenderCallback && Children.count(children); + + const [selectedTerms, hasResolvedSelectedTerms] = useSelectedTerms(taxonomyName); + const [taxonomy, hasResolvedTaxonomy] = useTaxonomy(taxonomyName); + + const { toggleProps, Popover } = usePopover(); + + if (!hasResolvedSelectedTerms || !hasResolvedTaxonomy) { + return ; + } + + const PostTaxonomiesTermSelector = taxonomy.hierarchical + ? PostTaxonomiesHierarchicalTermSelector + : PostTaxonomiesFlatTermSelector; + + if (hasRenderCallback) { + return children({ selectedTerms, isEditable }); + } + + let listElementProps = { + ...rest, + }; + + if (isEditable) { + listElementProps = { + ...listElementProps, + ...toggleProps, + }; + } + + if (hasChildComponents) { + return ( + <> + + {selectedTerms.map((term) => ( + + {children} + + ))} + + {isEditable && ( + + + + )} + + ); + } + + return ( + <> + + {selectedTerms.map((term) => ( +

  • + {term.name} +
  • + ))} + + {isEditable && ( + + + + )} + + ); +}; + +PostTermList.propTypes = { + children: PropTypes.oneOfType([PropTypes.node, PropTypes.func]), + taxonomyName: PropTypes.string, + tagName: PropTypes.string, +}; + +PostTermList.defaultProps = { + children: null, + tagName: 'ul', + taxonomyName: 'category', +}; + +PostTermList.ListItem = ListItem; +PostTermList.TermLink = TermLink; diff --git a/components/post-term-list/item.js b/components/post-term-list/item.js new file mode 100644 index 00000000..bdcdaf5d --- /dev/null +++ b/components/post-term-list/item.js @@ -0,0 +1,28 @@ +import { useContext } from '@wordpress/element'; +import PropTypes from 'prop-types'; +import { PostTermContext } from './context'; + +export const ListItem = (props) => { + const { tagName: TagName, children, ...rest } = props; + + return {children}; +}; + +ListItem.propTypes = { + tagName: PropTypes.string, + children: PropTypes.node.isRequired, +}; + +ListItem.defaultProps = { + tagName: 'li', +}; + +export const TermLink = (props) => { + const { link, name } = useContext(PostTermContext); + + return ( + + {name} + + ); +}; diff --git a/components/post-term-list/readme.md b/components/post-term-list/readme.md new file mode 100644 index 00000000..921f88d6 --- /dev/null +++ b/components/post-term-list/readme.md @@ -0,0 +1,71 @@ +# PostTermList + +The `PostTermList` component is part of the suite of components that read/write data from the global post object or the current [``](../post-context/). It has a composable declarative approach to allow you full control over the markup without needing to worry about the data handling at all. + +The Component allows you to showcase a list of the selected terms of the provided taxonomy of the current post. + +## Usage + +```js +import { PostTermList } from '@10up/block-components'; + +function BlockEdit() { + + return ( + + + + + + ) +} +``` + +### Output + +```html + +``` + +## Props + +| Name | Type | Default | Description | +| ---------- | ----------------- | -------- | -------------------------------------------------------------- | +| `context` | `object` | `{}` | | +| `children` | `function\|node\|null` | `null` | | +| `taxonomyName` | `string` | `category` | | +| `tagName` | `string` | `ul` | | +| `...rest` | `object` | `{}` | | + +## Child Components + +You can use this component in three different ways. Without any children it will just return some default markup containing the name of the term. Alternatively you can pass a render function as the children. This render function will get the term object passed into it. The third option is using the provided sub-components as shown in the example code. Each of these child component again automatically manages the data and allows you to just focus on the markup. + +## Sub-Components + +These are all the available sub-components of the `PostTermList` component. + +### `PostTermList.ListItem` + +Returns the list item markup + +#### Props + +| Name | Type | Default | Description | +| ---------- | ----------------- | -------- | -------------------------------------------------------------- | +| `tagName` | `string` | `li` | the tag name that should be used for the element | +| `...rest` | `object` | `{}` | | + +### `PostTermList.TermLink` + +Returns the anchor element containing the title of the term + +#### Props + +| Name | Type | Default | Description | +| ---------- | ----------------- | -------- | -------------------------------------------------------------- | +| `...rest` | `object` | `{}` | | diff --git a/components/post-title/index.js b/components/post-title/index.js new file mode 100644 index 00000000..237c9ecd --- /dev/null +++ b/components/post-title/index.js @@ -0,0 +1,46 @@ +import { useEntityProp } from '@wordpress/core-data'; +import { RichText, store as blockEditorStore } from '@wordpress/block-editor'; +import { useSelect } from '@wordpress/data'; +import PropTypes from 'prop-types'; +import { usePost } from '../../hooks'; + +export const PostTitle = (props) => { + const { tagName: TagName, ...rest } = props; + const { postId, postType, isEditable } = usePost(); + + const [rawTitle = '', setTitle, fullTitle] = useEntityProp( + 'postType', + postType, + 'title', + postId, + ); + + const titlePlaceholder = useSelect( + (select) => select(blockEditorStore).getSettings().titlePlaceholder, + [], + ); + + if (!isEditable) { + // eslint-disable-next-line react/no-danger + return ; + } + + return ( + + ); +}; + +PostTitle.propTypes = { + tagName: PropTypes.string, +}; + +PostTitle.defaultProps = { + tagName: 'h1', +}; diff --git a/components/post-title/readme.md b/components/post-title/readme.md new file mode 100644 index 00000000..39670f5a --- /dev/null +++ b/components/post-title/readme.md @@ -0,0 +1,25 @@ +# Post Title + +The `PostTitle` component is part of the suite of components that read/write data from the global post object or the current [``](../post-context/). It has a composable declarative approach to allow you full control over the markup without needing to worry about the data handling at all. + +The Component allows you to showcase the title of the current post. + +## Usage + +```js +import { PostTitle } from '@10up/block-components'; + +function BlockEdit() { + + return ( + + ) +} +``` + +## Props + +| Name | Type | Default | Description | +| ---------- | ----------------- | -------- | -------------------------------------------------------------- | +| `tagName` | `string` | `h1` | | +| `...rest` | `object` | `{}` | | diff --git a/cypress/e2e/Link.spec.js b/cypress/e2e/Link.spec.js index 2aeda59a..fb676272 100644 --- a/cypress/e2e/Link.spec.js +++ b/cypress/e2e/Link.spec.js @@ -13,7 +13,7 @@ context('Link', () => { // create the first link cy.get('.tenup-block-components-link__label').first().click(); cy.wait(1500); - cy.get('.tenup-block-components-link__label').first().type('First Link Label', { delay: 50, waitForAnimations: true }); + cy.get('.tenup-block-components-link__label').first().scrollIntoView({offset: {top: 100}}).type('First Link Label', { delay: 50, waitForAnimations: true }); cy.get('.block-editor-url-input__input').first().type('https://10up.com/', { delay: 50, waitForAnimations: true }); cy.get('button.block-editor-link-control__search-submit').first().click(); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 941d7708..8961873b 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -58,6 +58,14 @@ Cypress.Commands.add('createPost', (options = {}) => { .contains(postType, { matchCase: false }) .click({ force: true }); + // close the Yoast SEO metabox + cy.get('.wpseo-metabox .handlediv').then(button => { + const isExpanded = button[0].getAttribute('aria-expanded') === 'true'; + if ( isExpanded ) { + cy.get('.wpseo-metabox .handlediv').click(); + } + }); + cy.wait(100); if (title !== '') { diff --git a/example/.wp-env.json b/example/.wp-env.json index 73a2230d..b25ad4bf 100644 --- a/example/.wp-env.json +++ b/example/.wp-env.json @@ -1,5 +1,5 @@ { - "plugins": [ "." ], + "plugins": [ ".", "https://downloads.wordpress.org/plugin/wordpress-seo.19.11.zip" ], "mappings": { "/var/www/html/images": "./images" } diff --git a/example/plugin.php b/example/plugin.php index 908eaf76..14bd0e4b 100644 --- a/example/plugin.php +++ b/example/plugin.php @@ -67,3 +67,61 @@ function register_block() { ] ); }; + +function register_book_custom_post_type() { + $labels = array( + 'name' => __( 'Books', 'tenup' ), + 'singular_name' => __( 'Book', 'tenup' ), + 'menu_name' => __( 'Books', 'tenup' ), + 'view_item' => __( 'View book', 'tenup' ), + ); + + $args = [ + 'labels' => $labels, + 'menu_icon' => 'dashicons-book', + 'supports' => [ 'title', 'editor', 'meta', 'custom-fields', 'revisions' ], + 'hierarchical' => false, + 'public' => true, + 'show_ui' => true, + 'show_in_menu' => true, + 'show_in_nav_menus' => true, + 'show_in_admin_bar' => true, + 'menu_position' => 20, + 'can_export' => true, + 'has_archive' => false, + 'exclude_from_search' => false, + 'publicly_queryable' => true, + 'template' => [], + 'template_lock' => false, + 'capability_type' => 'post', + 'show_in_rest' => true, + ]; + + register_post_type( 'books', $args ); + + register_post_meta( 'books', 'author', [ + 'type' => 'string', + 'single' => true, + 'show_in_rest' => true, + ] ); + + register_post_meta( 'books', 'isbn', [ + 'type' => 'string', + 'single' => true, + 'show_in_rest' => true, + ] ); + + register_post_meta( 'books', 'price', [ + 'type' => 'number', + 'single' => true, + 'show_in_rest' => true, + ] ); + + register_post_meta( 'books', 'is_featured', [ + 'type' => 'boolean', + 'single' => true, + 'show_in_rest' => true, + ] ); +} + +add_action( 'init', __NAMESPACE__ . '\register_book_custom_post_type' ); diff --git a/example/src/blocks/content-item/index.js b/example/src/blocks/content-item/index.js new file mode 100644 index 00000000..0f839ab4 --- /dev/null +++ b/example/src/blocks/content-item/index.js @@ -0,0 +1,62 @@ +import { registerBlockType } from '@wordpress/blocks'; +import { useBlockProps } from '@wordpress/block-editor'; +import { __ } from '@wordpress/i18n'; + +import { + PostContext, + PostFeaturedImage, + PostTitle, + PostPrimaryCategory, + PostDate, + PostCategoryList, + PostAuthor, + PostExcerpt +} from '@10up/block-components'; + +const NAMESPACE = 'example'; + +registerBlockType(`${NAMESPACE}/content-item`, { + apiVersion: 2, + title: __('Content Item', NAMESPACE), + icon: 'smiley', + category: 'common', + example: {}, + supports: { + html: false + }, + attributes: {}, + transforms: {}, + variations: [], + usesContext: ['postId', 'postType', 'queryId'], + ancestor: ['core/post-template'], + edit: ({ context }) => { + const blockProps = useBlockProps(); + return ( +
    + +
    + +
    + + + + + + + + + + + + + + + + + +
    +
    + ) + }, + save: () => null +}); diff --git a/example/src/blocks/hero/index.js b/example/src/blocks/hero/index.js new file mode 100644 index 00000000..a72645b2 --- /dev/null +++ b/example/src/blocks/hero/index.js @@ -0,0 +1,51 @@ +import { registerBlockType } from '@wordpress/blocks'; +import { useBlockProps } from '@wordpress/block-editor'; +import { __ } from '@wordpress/i18n'; +import { header } from '@wordpress/icons'; + +import { PostFeaturedImage, PostTitle, PostPrimaryCategory, PostDate, PostCategoryList, PostAuthor, PostExcerpt } from '@10up/block-components'; + +const NAMESPACE = 'example'; + +registerBlockType(`${NAMESPACE}/hero`, { + apiVersion: 2, + title: __('Hero', NAMESPACE), + icon: header, + category: 'common', + example: {}, + supports: { + html: false, + }, + attributes: { + }, + transforms: {}, + variations: [], + parent: ['core/post-content'], + edit: () => { + const blockProps = useBlockProps({ className: 'alignwide' }); + return ( +
    +
    + +
    + + + + + + + + + + + + + + + + +
    + ) + }, + save: () => null +}); diff --git a/example/src/blocks/post-featured-image/index.js b/example/src/blocks/post-featured-image/index.js new file mode 100644 index 00000000..126f043f --- /dev/null +++ b/example/src/blocks/post-featured-image/index.js @@ -0,0 +1,31 @@ +import { registerBlockType } from '@wordpress/blocks'; +import { useBlockProps } from '@wordpress/block-editor'; +import { __ } from '@wordpress/i18n'; + +import { PostFeaturedImage } from '@10up/block-components'; + +const NAMESPACE = 'example'; + +registerBlockType(`${NAMESPACE}/custom-post-featured-image`, { + apiVersion: 2, + title: __('Custom Post Featured Image', NAMESPACE), + icon: 'format-image', + category: 'common', + example: {}, + supports: { + html: false + }, + attributes: {}, + transforms: {}, + variations: [], + usesContext: ['postId', 'postType', 'queryId'], + edit: ({ context }) => { + const blockProps = useBlockProps(); + return ( +
    + +
    + ) + }, + save: () => null +}); diff --git a/example/src/blocks/post-meta/block.json b/example/src/blocks/post-meta/block.json new file mode 100644 index 00000000..6fe6007c --- /dev/null +++ b/example/src/blocks/post-meta/block.json @@ -0,0 +1,12 @@ +{ + "apiVersion": 2, + "name": "example/post-meta", + "description": "Set some Metadata", + "title": "Post Meta", + "attributes": { + "metaKey": { + "type": "string", + "default": "" + } + } +} \ No newline at end of file diff --git a/example/src/blocks/post-meta/index.js b/example/src/blocks/post-meta/index.js new file mode 100644 index 00000000..d212a733 --- /dev/null +++ b/example/src/blocks/post-meta/index.js @@ -0,0 +1,133 @@ +import { registerBlockType, registerBlockVariation, store as blocksStore, createBlocksFromInnerBlocksTemplate } from '@wordpress/blocks'; +import { + useBlockProps, __experimentalBlockVariationPicker as BlockVariationPicker, store as blockEditorStore, +} from '@wordpress/block-editor'; +import { select, subscribe, useSelect, useDispatch } from '@wordpress/data'; +import { PostMeta } from '@10up/block-components'; +import { box } from '@wordpress/icons'; + +// Checks whether character is Uppercase. +// Crude version. Checks only A-Z. +function isCaps(char) { + if (char.match(/[A-Z]/)) return true; + return false; +} + +// Checks whether character is digit. +function isDigit(char) { + if (char.match(/[0-9]/)) return true; + return false; +} + +export function toKebab(string) { + return string + .split('') + .map((letter, index) => { + const previousLetter = string[index - 1] || ''; + const currentLetter = letter; + + if (isDigit(currentLetter) && !isDigit(previousLetter)) { + return `-${currentLetter}`; + } + + if (!isCaps(currentLetter)) return currentLetter; + + if (previousLetter === '') { + return `${currentLetter.toLowerCase()}`; + } + + if (isCaps(previousLetter)) { + return `${currentLetter.toLowerCase()}`; + } + + return `-${currentLetter.toLowerCase()}`; + }) + .join('') + .trim() + .replace(/[-_\s]+/g, '-'); +} + +export function toSentence(string) { + const interim = toKebab(string).replace(/-/g, ' '); + return interim.slice(0, 1).toUpperCase() + interim.slice(1); +} + +import metadata from './block.json'; + +registerBlockType(metadata, { + icon: box, + edit: (props) => { + const { attributes, setAttributes, name } = props; + const { metaKey } = attributes; + const blockProps = useBlockProps(); + + const { replaceInnerBlocks } = useDispatch(blockEditorStore); + + const variations = useSelect( + (select) => { + const { getBlockVariations } = select(blocksStore); + return getBlockVariations(name, 'block'); + }, + [name], + ); + + if (!metaKey) { + return ( +
    + { + if (nextVariation.attributes) { + setAttributes(nextVariation.attributes); + } + if (nextVariation.innerBlocks) { + replaceInnerBlocks( + clientId, + createBlocksFromInnerBlocksTemplate(nextVariation.innerBlocks), + true, + ); + } + }} + /> +
    + ) + } + + return ( +
    + +
    + ); + }, + save: () => null +}) + +let availableKeys = []; +subscribe(() => { + const meta = select('core/editor').getCurrentPostAttribute('meta'); + if (!meta) { + return; + } + + const keys = Object.keys(meta); + + if (JSON.stringify(keys) === JSON.stringify(availableKeys)) { + return; + } + + availableKeys = keys; + + const newVariations = availableKeys.map(metaKey => ({ + title: toSentence(metaKey) + ' - Meta', + description: `Displays the value of the meta key: "${metaKey}"`, + name: metaKey, + scope: ['inserter', 'block'], + attributes: { + metaKey: metaKey + }, + isActive: ['metaKey'] + })); + + registerBlockVariation('example/post-meta', newVariations); + +}); \ No newline at end of file diff --git a/example/src/blocks/post-title/index.js b/example/src/blocks/post-title/index.js new file mode 100644 index 00000000..378df9f3 --- /dev/null +++ b/example/src/blocks/post-title/index.js @@ -0,0 +1,31 @@ +import { registerBlockType } from '@wordpress/blocks'; +import { useBlockProps } from '@wordpress/block-editor'; +import { __ } from '@wordpress/i18n'; + +import { PostTitle } from '@10up/block-components'; + +const NAMESPACE = 'example'; + +registerBlockType( `${ NAMESPACE }/custom-post-title`, { + apiVersion: 2, + title: __( 'Custom Post Title', NAMESPACE ), + icon: 'smiley', + category: 'common', + example: {}, + supports: { + html: false + }, + attributes: {}, + transforms: {}, + variations: [], + usesContext: [ 'postId', 'postType', 'queryId' ], + edit: ({context}) => { + const blockProps = useBlockProps(); + return ( +
    + +
    + ) + }, + save: () => null +} ); diff --git a/example/src/index.js b/example/src/index.js index 971797d1..64fc41c9 100644 --- a/example/src/index.js +++ b/example/src/index.js @@ -5,3 +5,8 @@ import './blocks/repeater-component-example'; import './blocks/link-example'; import './blocks/image-example'; import './blocks/rich-text-character-limit'; +import './blocks/post-title'; +import './blocks/post-featured-image'; +import './blocks/content-item'; +import './blocks/hero'; +import './blocks/post-meta'; diff --git a/hooks/index.js b/hooks/index.js index e722e582..38767900 100644 --- a/hooks/index.js +++ b/hooks/index.js @@ -4,4 +4,15 @@ export { useIcons, useIcon } from './use-icons'; export { useFilteredList } from './use-filtered-list'; export { useMedia } from './use-media'; export { useBlockParentAttributes } from './use-block-parent-attributes'; +export { usePost } from './use-post'; +export { useIsSupportedTaxonomy } from './use-is-supported-taxonomy'; +export { useAllTerms } from './use-all-terms'; +export { useSelectedTermIds } from './use-selected-term-ids'; +export { useSelectedTerms } from './use-selected-terms'; +export { useSelectedTermsOfSavedPost } from './use-selected-terms-of-saved-post'; +export { useIsPluginActive } from './use-is-plugin-active'; +export { usePrimaryTerm } from './use-primary-term'; +export { usePopover } from './use-popover'; export { useScript } from './use-script'; +export { usePostMetaValue } from './use-post-meta-value'; +export { useTaxonomy } from './use-taxonomy'; diff --git a/hooks/use-all-terms/index.js b/hooks/use-all-terms/index.js new file mode 100644 index 00000000..5b4ac5bf --- /dev/null +++ b/hooks/use-all-terms/index.js @@ -0,0 +1,25 @@ +import { useSelect } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; + +export const useAllTerms = (taxonomyName) => { + return useSelect( + (select) => { + const { getEntityRecords, hasFinishedResolution } = select(coreStore); + + const termsSelector = [ + 'taxonomy', + taxonomyName, + { + per_page: -1, + }, + ]; + + const terms = getEntityRecords(...termsSelector); + + const hasResolvedTerms = hasFinishedResolution('getEntityRecords', termsSelector); + + return [terms, hasResolvedTerms]; + }, + [taxonomyName], + ); +}; diff --git a/hooks/use-all-terms/readme.md b/hooks/use-all-terms/readme.md new file mode 100644 index 00000000..397f51db --- /dev/null +++ b/hooks/use-all-terms/readme.md @@ -0,0 +1,21 @@ +# `useAllTerms` + +The `useAllTerms` hook is a simple utility that makes it easy to get all terms from a taxonomy. + +## Usage + +```js +import { useAllTerms } from '@10up/block-components'; + +function BlockEdit(props) { + const [categories, hasResolvedCategories] = useAllTerms('category'); + + if ( ! hasResolvedCategories ) { + return + } + + return ( + ... + ); +} +``` diff --git a/hooks/use-is-plugin-active/index.js b/hooks/use-is-plugin-active/index.js new file mode 100644 index 00000000..4f44ebe1 --- /dev/null +++ b/hooks/use-is-plugin-active/index.js @@ -0,0 +1,20 @@ +import { useSelect } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; + +const ACTIVE_STATUSES = ['active', 'network-active']; + +export const useIsPluginActive = (pluginName) => { + return useSelect( + (select) => { + const plugin = select(coreStore).getPlugin(pluginName); + const hasResolvedPlugins = select(coreStore).hasFinishedResolution('getPlugin', [ + pluginName, + ]); + + const isPluginActive = ACTIVE_STATUSES.includes(plugin?.status); + + return [isPluginActive, hasResolvedPlugins]; + }, + [pluginName], + ); +}; diff --git a/hooks/use-is-plugin-active/readme.md b/hooks/use-is-plugin-active/readme.md new file mode 100644 index 00000000..dc6b6a5b --- /dev/null +++ b/hooks/use-is-plugin-active/readme.md @@ -0,0 +1,21 @@ +# `useIsPluginActive` + +The `useIsPluginActive` hook is a simple utility that returns whether or not a given plugin is activated. + +## Usage + +```js +import { useIsPluginActive } from '@10up/block-components'; + +function BlockEdit(props) { + const [isYoastSeoActive, hasResolvedIsPluginActive] = useIsPluginActive('wordpress-seo/wp-seo'); + + if ( ! hasResolvedIsPluginActive ) { + return + } + + return ( + ... + ); +} +``` diff --git a/hooks/use-is-supported-taxonomy/index.js b/hooks/use-is-supported-taxonomy/index.js new file mode 100644 index 00000000..c954909d --- /dev/null +++ b/hooks/use-is-supported-taxonomy/index.js @@ -0,0 +1,20 @@ +import { useSelect } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; + +export const useIsSupportedTaxonomy = (postType, taxonomyName) => { + return useSelect( + (select) => { + const postTypeObject = select(coreStore).getPostType(postType); + const hasResolvedPostType = select(coreStore).hasFinishedResolution('getPostType', [ + postType, + ]); + + const isSupportedTaxonomy = postTypeObject?.taxonomies?.some( + (name) => name === taxonomyName, + ); + + return [!!isSupportedTaxonomy, hasResolvedPostType]; + }, + [postType, taxonomyName], + ); +}; diff --git a/hooks/use-is-supported-taxonomy/readme.md b/hooks/use-is-supported-taxonomy/readme.md new file mode 100644 index 00000000..2c89dac8 --- /dev/null +++ b/hooks/use-is-supported-taxonomy/readme.md @@ -0,0 +1,26 @@ +# `useIsSupportedTaxonomy` + +The `useIsSupportedTaxonomy` hook is a simple utility that returns whether or not a given taxonomy is supported on a given post type. + +## Usage + +```js +import { useIsSupportedTaxonomy } from '@10up/block-components'; + +function BlockEdit(props) { + const { context } = props; + const { postType } = context; + const [isSupportingCategoryTaxonomy, hasResolvedIsSupportingCategoryTaxonomy] = useIsSupportedTaxonomy( + postType, + 'category', + ); + + if ( ! hasResolvedIsSupportingCategoryTaxonomy ) { + return + } + + return ( + ... + ); +} +``` diff --git a/hooks/use-popover/index.js b/hooks/use-popover/index.js new file mode 100644 index 00000000..db96c5b6 --- /dev/null +++ b/hooks/use-popover/index.js @@ -0,0 +1,34 @@ +import { Popover } from '@wordpress/components'; +import { useState } from '@wordpress/element'; +import { useOnClickOutside } from '../use-on-click-outside'; + +export const usePopover = () => { + // Use internal state instead of a ref to make sure that the component + // re-renders when the popover's anchor updates. + const [popoverAnchor, setPopoverAnchor] = useState(); + const [isVisible, setIsVisible] = useState(false); + const toggleVisible = () => { + setIsVisible(!isVisible); + }; + + const toggleProps = { + onClick: toggleVisible, + 'aria-expanded': isVisible, + ref: setPopoverAnchor, + }; + + const ref = useOnClickOutside(() => setIsVisible(false)); + + return { + setPopoverAnchor, + toggleVisible, + toggleProps, + // eslint-disable-next-line react/prop-types + Popover: ({ children }) => + isVisible ? ( + +
    {children}
    +
    + ) : null, + }; +}; diff --git a/hooks/use-popover/readme.md b/hooks/use-popover/readme.md new file mode 100644 index 00000000..18da8304 --- /dev/null +++ b/hooks/use-popover/readme.md @@ -0,0 +1,24 @@ +# `usePopover` + +The `usePopover` hook is a simple utility allows you to easily add Popovers to any element. + +## Usage + +```js +import { usePopover } from '@10up/block-components'; + +function BlockEdit(props) { + const { toggleProps, Popover } = usePopover(); + + return ( + <> + + + I'm rendered inside a Popover + + + ); +} +``` diff --git a/hooks/use-post-meta-value/index.js b/hooks/use-post-meta-value/index.js new file mode 100644 index 00000000..eab975fc --- /dev/null +++ b/hooks/use-post-meta-value/index.js @@ -0,0 +1,17 @@ +import { useEntityProp } from '@wordpress/core-data'; +import { usePost } from '../use-post'; + +export const usePostMetaValue = (metaKey) => { + const { postId, postType } = usePost(); + const [meta, setMeta] = useEntityProp('postType', postType, 'meta', postId); + + const metaValue = meta[metaKey]; + const setMetaValue = (newValue) => { + setMeta({ + ...meta, + [metaKey]: newValue, + }); + }; + + return [metaValue, setMetaValue]; +}; diff --git a/hooks/use-post-meta-value/readme.md b/hooks/use-post-meta-value/readme.md new file mode 100644 index 00000000..b6139086 --- /dev/null +++ b/hooks/use-post-meta-value/readme.md @@ -0,0 +1,17 @@ +# `usePostMetaValue` + +The `usePostMetaValue` hook allows you to read and write meta values of the current post. It either references the global post, or when used within a [``](../../components/post-context/) it reference that post. + +## Usage + +```js +import { usePostMetaValue } from '@10up/block-components'; + +function BlockEdit(props) { + const [price, setPrice] = usePostMetaValue( 'price' ); + + return ( + ... + ); +} +``` diff --git a/hooks/use-post/index.js b/hooks/use-post/index.js new file mode 100644 index 00000000..e78703dd --- /dev/null +++ b/hooks/use-post/index.js @@ -0,0 +1,27 @@ +import { useSelect } from '@wordpress/data'; +import { store as editorStore } from '@wordpress/editor'; +import { usePostContext } from '../../components/post-context/context'; + +export function usePost() { + const { + postId: blockContextPostId, + postType: blockContextPostType, + isEditable: blockContextIsEditable, + } = usePostContext(); + + const { globalPostId, globalPostType } = useSelect( + (select) => ({ + globalPostId: select(editorStore).getCurrentPostId(), + globalPostType: select(editorStore).getCurrentPostType(), + }), + [], + ); + + const hasBlockContext = !!blockContextPostId && !!blockContextPostType; + + return { + postId: blockContextPostId || globalPostId, + postType: blockContextPostType || globalPostType, + isEditable: hasBlockContext ? blockContextIsEditable : true, + }; +} diff --git a/hooks/use-post/readme.md b/hooks/use-post/readme.md new file mode 100644 index 00000000..b22ac247 --- /dev/null +++ b/hooks/use-post/readme.md @@ -0,0 +1,21 @@ +# `usePost` + +The `usePost` hook allows you to get information about the current post. It either references the global post, or when used within a [``](../../components/post-context/) it reference that post. + +## Usage + +```js +import { usePost } from '@10up/block-components'; + +function BlockEdit(props) { + const { + postId, + postType, + isEditable + } = usePost(); + + return ( + ... + ); +} +``` diff --git a/hooks/use-primary-term/index.js b/hooks/use-primary-term/index.js new file mode 100644 index 00000000..ff3c54e0 --- /dev/null +++ b/hooks/use-primary-term/index.js @@ -0,0 +1,62 @@ +import { useSelect } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; +import { usePost } from '../use-post'; +import { useIsPluginActive } from '../use-is-plugin-active'; +import { useIsSupportedTaxonomy } from '../use-is-supported-taxonomy'; + +export const usePrimaryTerm = (taxonomyName) => { + const { postType, isEditable } = usePost(); + + const [isYoastSeoActive, hasResolvedIsPluginActive] = useIsPluginActive('wordpress-seo/wp-seo'); + const [isSupportedTaxonomy, hasResolvedIsSupportedTaxonomy] = useIsSupportedTaxonomy( + postType, + taxonomyName, + ); + + const primaryTermId = useSelect( + (select) => { + if (!hasResolvedIsSupportedTaxonomy || !hasResolvedIsPluginActive) { + return null; + } + + if (!isYoastSeoActive && hasResolvedIsPluginActive) { + // eslint-disable-next-line no-console + console.error( + 'Yoast SEO is not active. Please install and activate Yoast SEO to use the PostPrimaryCategory component.', + ); + return null; + } + + if (!isSupportedTaxonomy && hasResolvedIsSupportedTaxonomy) { + // eslint-disable-next-line no-console + console.error( + `The taxonomy "${taxonomyName}" is not supported for the post type "${postType}". Please use a supported taxonomy.`, + ); + return null; + } + + return select('yoast-seo/editor').getPrimaryTaxonomyId(taxonomyName); + }, + [ + taxonomyName, + isYoastSeoActive, + isSupportedTaxonomy, + hasResolvedIsSupportedTaxonomy, + hasResolvedIsPluginActive, + ], + ); + + const primaryTerm = useSelect( + (select) => { + if (!primaryTermId) { + return null; + } + + const { getEntityRecord } = select('core'); + return getEntityRecord('taxonomy', taxonomyName, primaryTermId); + }, + [primaryTermId], + ); + + return [!isEditable ? { name: __('Primary Term', 'tenup') } : primaryTerm, isSupportedTaxonomy]; +}; diff --git a/hooks/use-primary-term/readme.md b/hooks/use-primary-term/readme.md new file mode 100644 index 00000000..914bd8a5 --- /dev/null +++ b/hooks/use-primary-term/readme.md @@ -0,0 +1,20 @@ +# `usePrimaryTerm` + +The `usePrimaryTerm` hook retrieves the primary term of any given taxonomy. + +> **Warning** +> This hook will not function without having [Yoast SEO](https://wordpress.org/plugins/wordpress-seo/) installed and activated. + +## Usage + +```js +import { usePrimaryTerm } from '@10up/block-components'; + +function BlockEdit(props) { + const [primaryCategory, isSupportingCategory] = usePrimaryTerm('category'); + + return ( + ... + ); +} +``` diff --git a/hooks/use-selected-term-ids/index.js b/hooks/use-selected-term-ids/index.js new file mode 100644 index 00000000..5fdc048d --- /dev/null +++ b/hooks/use-selected-term-ids/index.js @@ -0,0 +1,19 @@ +import { store as editorStore } from '@wordpress/editor'; +import { useSelect } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; + +export const useSelectedTermIds = (taxonomyName) => { + return useSelect( + (select) => { + const { getTaxonomy, hasFinishedResolution } = select(coreStore); + const taxonomyObject = getTaxonomy(taxonomyName); + const hasResolvedTaxonomyObject = hasFinishedResolution('getTaxonomy', [taxonomyName]); + const { getEditedPostAttribute } = select(editorStore); + + const selectedTermIds = getEditedPostAttribute(taxonomyObject?.rest_base); + + return [selectedTermIds, hasResolvedTaxonomyObject]; + }, + [taxonomyName], + ); +}; diff --git a/hooks/use-selected-term-ids/readme.md b/hooks/use-selected-term-ids/readme.md new file mode 100644 index 00000000..44d9025b --- /dev/null +++ b/hooks/use-selected-term-ids/readme.md @@ -0,0 +1,17 @@ +# `useSelectedTermIds` + +The `useSelectedTermIds` hook retrieves ids of the selected terms of the provided taxonomy. It gets used internally by the [`useSelectedTerms`](../use-selected-terms/) hook which is a more full featured version of this hook. + +## Usage + +```js +import { useSelectedTermIds } from '@10up/block-components'; + +function BlockEdit(props) { + const [selectedCategoryIds, hasResolvedSelectedCategoryIds] = useSelectedTermIds('category'); + + return ( + ... + ); +} +``` diff --git a/hooks/use-selected-terms-of-saved-post/index.js b/hooks/use-selected-terms-of-saved-post/index.js new file mode 100644 index 00000000..c271c022 --- /dev/null +++ b/hooks/use-selected-terms-of-saved-post/index.js @@ -0,0 +1,24 @@ +import { useSelect } from '@wordpress/data'; + +export const useSelectedTermsOfSavedPost = (taxonomyName, postId) => { + return useSelect( + (select) => { + const { getEntityRecords, hasFinishedResolution } = select('core'); + + const selectedTermsQuery = [ + 'taxonomy', + taxonomyName, + { + per_page: -1, + post: postId, + }, + ]; + + return [ + getEntityRecords(...selectedTermsQuery), + hasFinishedResolution('getEntityRecords', selectedTermsQuery), + ]; + }, + [taxonomyName, postId], + ); +}; diff --git a/hooks/use-selected-terms-of-saved-post/readme.md b/hooks/use-selected-terms-of-saved-post/readme.md new file mode 100644 index 00000000..b9112fad --- /dev/null +++ b/hooks/use-selected-terms-of-saved-post/readme.md @@ -0,0 +1,22 @@ +# `useSelectedTermsOfSavedPost` + +The `useSelectedTermsOfSavedPost` hook retrieves the ids of the selected terms of the provided taxonomy. It gets used internally by the [`useSelectedTerms`](../use-selected-terms/) hook which is a more full featured version of this hook. + +## Usage + +```js +import { useSelectedTermsOfSavedPost } from '@10up/block-components'; + +function BlockEdit(props) { + const { context } = props; + const { postId } = context; + const [ + selectedCategoriesOfSavedPost, + hasResolvedSelectedCategoriesOfSavedPost + ] = useSelectedTermsOfSavedPost('category', postId); + + return ( + ... + ); +} +``` diff --git a/hooks/use-selected-terms/index.js b/hooks/use-selected-terms/index.js new file mode 100644 index 00000000..2769a332 --- /dev/null +++ b/hooks/use-selected-terms/index.js @@ -0,0 +1,45 @@ +import { usePost } from '../use-post'; +import { useIsSupportedTaxonomy } from '../use-is-supported-taxonomy'; +import { useAllTerms } from '../use-all-terms'; +import { useSelectedTermIds } from '../use-selected-term-ids'; +import { useSelectedTermsOfSavedPost } from '../use-selected-terms-of-saved-post'; + +export const useSelectedTerms = (taxonomyName) => { + const { postId, postType, isEditable } = usePost(); + const [isSupportedTaxonomy, hasResolvedIsSupportedTaxonomy] = useIsSupportedTaxonomy( + postType, + taxonomyName, + ); + const [selectedTermIds, hasResolvedSelectedTermIds] = useSelectedTermIds(taxonomyName); + const [terms, hasResolvedTerms] = useAllTerms(taxonomyName); + const [selectedTermsOfSavedPost, hasResolvedSelectedTermsOfSavedPost] = + useSelectedTermsOfSavedPost(taxonomyName, postId); + + if (!hasResolvedIsSupportedTaxonomy) { + return [[], false]; + } + + if (!isSupportedTaxonomy && hasResolvedIsSupportedTaxonomy) { + // eslint-disable-next-line no-console + console.error( + `The taxonomy "${taxonomyName}" is not supported for the post type "${postType}". Please use a supported taxonomy.`, + ); + return [[], true]; + } + + if ( + (!isEditable && !hasResolvedSelectedTermsOfSavedPost) || + (isEditable && (!hasResolvedTerms || !hasResolvedSelectedTermIds)) + ) { + return [[], false]; + } + + if (!isEditable && hasResolvedSelectedTermsOfSavedPost) { + return [selectedTermsOfSavedPost, hasResolvedSelectedTermsOfSavedPost]; + } + + return [ + terms.filter((term) => selectedTermIds?.includes(term.id)), + hasResolvedTerms && hasResolvedSelectedTermIds, + ]; +}; diff --git a/hooks/use-selected-terms/readme.md b/hooks/use-selected-terms/readme.md new file mode 100644 index 00000000..cc981f08 --- /dev/null +++ b/hooks/use-selected-terms/readme.md @@ -0,0 +1,17 @@ +# `useSelectedTerms` + +The `useSelectedTerms` hook retrieves the term objects of the selected terms of the provided taxonomy. + +## Usage + +```js +import { useSelectedTerms } from '@10up/block-components'; + +function BlockEdit(props) { + const [selectedCategories, hasResolvedSelectedCategories] = useSelectedTerms('category'); + + return ( + ... + ); +} +``` diff --git a/hooks/use-taxonomy/index.js b/hooks/use-taxonomy/index.js new file mode 100644 index 00000000..ba153ff1 --- /dev/null +++ b/hooks/use-taxonomy/index.js @@ -0,0 +1,16 @@ +import { useSelect } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; + +export function useTaxonomy(taxonomyName) { + return useSelect( + (select) => { + const { getTaxonomy, hasFinishedResolution } = select(coreStore); + + const hasResolvedTaxonomy = hasFinishedResolution('getTaxonomy', [taxonomyName]); + const taxonomy = getTaxonomy(taxonomyName); + + return [taxonomy, hasResolvedTaxonomy]; + }, + [taxonomyName], + ); +} diff --git a/hooks/use-taxonomy/readme.md b/hooks/use-taxonomy/readme.md new file mode 100644 index 00000000..d27b53b0 --- /dev/null +++ b/hooks/use-taxonomy/readme.md @@ -0,0 +1,21 @@ +# `useTaxonomy` + +The `useTaxonomy` hook is a simple utility that returns the taxonomy object for any given taxonomy. + +## Usage + +```js +import { useTaxonomy } from '@10up/block-components'; + +function BlockEdit(props) { + const [postTag, hasResolvedPostTag] = useTaxonomy('post_tag'); + + if ( ! hasResolvedPostTag ) { + return + } + + return ( + ... + ); +} +``` diff --git a/images/block-editor-unsaved-external-change.png b/images/block-editor-unsaved-external-change.png new file mode 100644 index 00000000..a2975512 Binary files /dev/null and b/images/block-editor-unsaved-external-change.png differ diff --git a/package-lock.json b/package-lock.json index c269edb0..af30cd10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@10up/block-components", - "version": "1.14.5", + "version": "1.14.6-alpha.5", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@10up/block-components", - "version": "1.14.5", + "version": "1.14.6-alpha.5", "license": "GPL-2.0-or-later", "workspaces": [ "./", @@ -150,7 +150,7 @@ }, "node_modules/@10up/cypress-wp-utils": { "version": "0.0.1", - "resolved": "git+ssh://git@github.com/10up/cypress-wp-utils.git#8bcb4c96c1cd0ec3533741a4e6ca519735a8b87d", + "resolved": "git+ssh://git@github.com/10up/cypress-wp-utils.git#ec20aed9c16f460f3e7d0c06c447f4fb8d030404", "dev": true, "license": "MIT", "engines": { @@ -20999,7 +20999,7 @@ } }, "@10up/cypress-wp-utils": { - "version": "git+ssh://git@github.com/10up/cypress-wp-utils.git#8bcb4c96c1cd0ec3533741a4e6ca519735a8b87d", + "version": "git+ssh://git@github.com/10up/cypress-wp-utils.git#ec20aed9c16f460f3e7d0c06c447f4fb8d030404", "dev": true, "from": "@10up/cypress-wp-utils@github:10up/cypress-wp-utils#build" }, @@ -33955,7 +33955,7 @@ } }, "@10up/cypress-wp-utils": { - "version": "git+ssh://git@github.com/10up/cypress-wp-utils.git#8bcb4c96c1cd0ec3533741a4e6ca519735a8b87d", + "version": "git+ssh://git@github.com/10up/cypress-wp-utils.git#ec20aed9c16f460f3e7d0c06c447f4fb8d030404", "dev": true, "from": "@10up/cypress-wp-utils@github:10up/cypress-wp-utils#build" }, diff --git a/package.json b/package.json index c52ae9ba..3cb776d9 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "publishConfig": { "access": "public" }, - "version": "1.14.5", + "version": "1.14.6-alpha.5", "description": "10up Components built for the WordPress Block Editor.", "main": "./dist/index.js", "source": "index.js",