diff --git a/admin/class-meta-columns.php b/admin/class-meta-columns.php index 7b2108fb750..900b4360031 100644 --- a/admin/class-meta-columns.php +++ b/admin/class-meta-columns.php @@ -355,6 +355,12 @@ protected function determine_seo_filters( $seo_filter ) { * @return array The Readability score filter. */ protected function determine_readability_filters( $readability_filter ) { + if ( $readability_filter === WPSEO_Rank::NO_FOCUS ) { + return $this->create_no_readability_scores_filter(); + } + if ( $readability_filter === WPSEO_Rank::BAD ) { + return $this->create_bad_readability_scores_filter(); + } $rank = new WPSEO_Rank( $readability_filter ); return $this->create_readability_score_filter( $rank->get_starting_score(), $rank->get_end_score() ); @@ -587,7 +593,7 @@ protected function build_filter_query( $vars, $filters ) { $current_seo_filter = $this->get_current_seo_filter(); // This only applies for the SEO score filter because it can because the SEO score can be altered by the no-index option. - if ( $this->is_valid_filter( $current_seo_filter ) && ! in_array( $current_seo_filter, [ WPSEO_Rank::NO_INDEX, WPSEO_Rank::NO_FOCUS ], true ) ) { + if ( $this->is_valid_filter( $current_seo_filter ) && ! in_array( $current_seo_filter, [ WPSEO_Rank::NO_INDEX ], true ) ) { $result['meta_query'] = array_merge( $result['meta_query'], [ $this->get_meta_robots_query_values() ] ); } @@ -600,7 +606,7 @@ protected function build_filter_query( $vars, $filters ) { * @param number $low The lower boundary of the score. * @param number $high The higher boundary of the score. * - * @return array The Readability Score filter. + * @return array> The Readability Score filter. */ protected function create_readability_score_filter( $low, $high ) { return [ @@ -619,7 +625,7 @@ protected function create_readability_score_filter( $low, $high ) { * @param number $low The lower boundary of the score. * @param number $high The higher boundary of the score. * - * @return array The SEO score filter. + * @return array> The SEO score filter. */ protected function create_seo_score_filter( $low, $high ) { return [ @@ -635,7 +641,7 @@ protected function create_seo_score_filter( $low, $high ) { /** * Creates a filter to retrieve posts that were set to no-index. * - * @return array Array containin the no-index filter. + * @return array> Array containin the no-index filter. */ protected function create_no_index_filter() { return [ @@ -650,20 +656,76 @@ protected function create_no_index_filter() { /** * Creates a filter to retrieve posts that have no keyword set. * - * @return array Array containing the no focus keyword filter. + * @return array> Array containing the no focus keyword filter. */ protected function create_no_focus_keyword_filter() { return [ [ - 'key' => WPSEO_Meta::$meta_prefix . 'meta-robots-noindex', + 'key' => WPSEO_Meta::$meta_prefix . 'linkdex', 'value' => 'needs-a-value-anyway', 'compare' => 'NOT EXISTS', ], + ]; + } + + /** + * Creates a filter to retrieve posts that have not been analyzed for readability yet. + * + * @return array> Array containing the no readability filter. + */ + protected function create_no_readability_scores_filter() { + // We check the existence of the Estimated Reading Time, because readability scores of posts that haven't been manually saved while Yoast SEO is active, don't exist, which is also the case for posts with not enough content. + // Meanwhile, the ERT is a solid indicator of whether a post has ever been saved (aka, analyzed), so we're using that. + $rank = new WPSEO_Rank( WPSEO_Rank::BAD ); + return [ [ - 'key' => WPSEO_Meta::$meta_prefix . 'linkdex', + 'key' => WPSEO_Meta::$meta_prefix . 'estimated-reading-time-minutes', 'value' => 'needs-a-value-anyway', 'compare' => 'NOT EXISTS', ], + [ + 'relation' => 'OR', + [ + 'key' => WPSEO_Meta::$meta_prefix . 'content_score', + 'value' => $rank->get_starting_score(), + 'type' => 'numeric', + 'compare' => '<', + ], + [ + 'key' => WPSEO_Meta::$meta_prefix . 'content_score', + 'value' => 'needs-a-value-anyway', + 'compare' => 'NOT EXISTS', + ], + ], + ]; + } + + /** + * Creates a filter to retrieve posts that have bad readability scores, including those that have not enough content to have one. + * + * @return array> Array containing the bad readability filter. + */ + protected function create_bad_readability_scores_filter() { + $rank = new WPSEO_Rank( WPSEO_Rank::BAD ); + return [ + 'relation' => 'OR', + [ + 'key' => WPSEO_Meta::$meta_prefix . 'content_score', + 'value' => [ $rank->get_starting_score(), $rank->get_end_score() ], + 'type' => 'numeric', + 'compare' => 'BETWEEN', + ], + [ + [ + 'key' => WPSEO_Meta::$meta_prefix . 'content_score', + 'value' => 'needs-a-value-anyway', + 'compare' => 'NOT EXISTS', + ], + [ + 'key' => WPSEO_Meta::$meta_prefix . 'estimated-reading-time-minutes', + 'compare' => 'EXISTS', + ], + ], ]; } @@ -705,7 +767,7 @@ protected function uses_default_indexing( $post_id ) { * * @param string $order_by The ID of the column by which to order the posts. * - * @return array Array containing the order filters. + * @return array Array containing the order filters. */ private function filter_order_by( $order_by ) { switch ( $order_by ) { diff --git a/composer.json b/composer.json index bddc56eb708..e6ded9d63d6 100644 --- a/composer.json +++ b/composer.json @@ -91,7 +91,7 @@ "Yoast\\WP\\SEO\\Composer\\Actions::check_coding_standards" ], "check-cs-thresholds": [ - "@putenv YOASTCS_THRESHOLD_ERRORS=2479", + "@putenv YOASTCS_THRESHOLD_ERRORS=2474", "@putenv YOASTCS_THRESHOLD_WARNINGS=252", "Yoast\\WP\\SEO\\Composer\\Actions::check_cs_thresholds" ], diff --git a/inc/class-wpseo-rank.php b/inc/class-wpseo-rank.php index e44c1e3c9ad..a8b9c101519 100644 --- a/inc/class-wpseo-rank.php +++ b/inc/class-wpseo-rank.php @@ -217,6 +217,11 @@ public function get_drop_down_readability_labels() { __( 'Readability: %s', 'wordpress-seo' ), __( 'Good', 'wordpress-seo' ) ), + self::NO_FOCUS => sprintf( + /* translators: %s expands to the readability score */ + __( 'Readability: %s', 'wordpress-seo' ), + __( 'Not analyzed', 'wordpress-seo' ) + ), ]; return $labels[ $this->rank ]; @@ -313,7 +318,7 @@ public static function get_all_ranks() { * @return WPSEO_Rank[] */ public static function get_all_readability_ranks() { - return array_map( [ 'WPSEO_Rank', 'create_rank' ], [ self::BAD, self::OK, self::GOOD ] ); + return array_map( [ 'WPSEO_Rank', 'create_rank' ], [ self::BAD, self::OK, self::GOOD, self::NO_FOCUS ] ); } /** diff --git a/packages/js/.eslintrc.js b/packages/js/.eslintrc.js index 212c1cfe589..ed4fe8ecd26 100644 --- a/packages/js/.eslintrc.js +++ b/packages/js/.eslintrc.js @@ -103,5 +103,12 @@ module.exports = { "react/display-name": 0, }, }, + // Ignore Proptypes in the dashboard. + { + files: [ "src/dashboard/**/*.js" ], + rules: { + "react/prop-types": 0, + }, + }, ], }; diff --git a/packages/js/src/dashboard/components/dashboard.js b/packages/js/src/dashboard/components/dashboard.js new file mode 100644 index 00000000000..62f58e02edc --- /dev/null +++ b/packages/js/src/dashboard/components/dashboard.js @@ -0,0 +1,34 @@ +import { Scores } from "../scores/components/scores"; +import { PageTitle } from "./page-title"; + +/** + * @type {import("../index").ContentType} ContentType + * @type {import("../index").Features} Features + * @type {import("../index").Endpoints} Endpoints + * @type {import("../index").Links} Links + */ + +/** + * @param {ContentType[]} contentTypes The content types. + * @param {string} userName The user name. + * @param {Features} features Whether features are enabled. + * @param {Endpoints} endpoints The endpoints. + * @param {Object} headers The headers for the score requests. + * @param {Links} links The links. + * @returns {JSX.Element} The element. + */ +export const Dashboard = ( { contentTypes, userName, features, endpoints, headers, links } ) => { + return ( + <> + +
+ { features.indexables && features.seoAnalysis && ( + + ) } + { features.indexables && features.readabilityAnalysis && ( + + ) } +
+ + ); +}; diff --git a/packages/js/src/dashboard/components/page-title.js b/packages/js/src/dashboard/components/page-title.js new file mode 100644 index 00000000000..a62fd0a05e5 --- /dev/null +++ b/packages/js/src/dashboard/components/page-title.js @@ -0,0 +1,67 @@ +import { createInterpolateElement } from "@wordpress/element"; +import { __, sprintf } from "@wordpress/i18n"; +import { Alert, Link, Paper, Title } from "@yoast/ui-library"; +import { OutboundLink } from "../../shared-admin/components"; + +/** + * @type {import("../index").Features} Features + * @type {import("../index").Links} Links + */ + +/** + * @param {string} userName The user name. + * @param {Features} features Whether features are enabled. + * @param {Links} links The links. + * @returns {JSX.Element} The element. + */ +export const PageTitle = ( { userName, features, links } ) => ( + + + + { sprintf( + __( "Hi %s,", "wordpress-seo" ), + userName + ) } + +

+ { features.indexables && ! features.seoAnalysis && ! features.readabilityAnalysis + ? createInterpolateElement( + sprintf( + /** + * translators: %1$s and %2$s expand to an opening and closing anchor tag, to the site features page. + * %3$s and %4$s expand to an opening and closing anchor tag, to the user profile page. + **/ + __( "It looks like the ‘SEO analysis’ and the ‘Readability analysis’ are currently disabled in your %1$sSite features%2$s or your %3$suser profile settings%4$s. Enable these features to start seeing all the insights you need right here!", "wordpress-seo" ), + "", + "", + "", + "" + ), + { + // Added dummy space as content to prevent children prop warnings in the console. + link: , + profilelink: , + } + ) + : createInterpolateElement( + sprintf( + /* translators: %1$s and %2$s expand to an opening and closing anchor tag. */ + __( "Welcome to your dashboard! Check your content's SEO performance, readability, and overall strengths and opportunities. %1$sLearn more on how to improve your content with our content analysis tool%2$s.", "wordpress-seo" ), + "", + "" + ), + { + // Added dummy space as content to prevent children prop warnings in the console. + link: , + } + ) + } +

+ { ! features.indexables && ( + + { __( "Oops! You can’t see the overview of your SEO scores and readability scores right now because you’re in a non-production environment.", "wordpress-seo" ) } + + ) } +
+
+); diff --git a/packages/js/src/dashboard/fetch/fetch-json.js b/packages/js/src/dashboard/fetch/fetch-json.js new file mode 100644 index 00000000000..62f95c6a9fb --- /dev/null +++ b/packages/js/src/dashboard/fetch/fetch-json.js @@ -0,0 +1,18 @@ +import { getResponseError } from "./get-response-error"; + +/** + * @param {string|URL} url The URL to fetch from. + * @param {RequestInit} options The request options. + * @returns {Promise} The promise of a result, or an error. + */ +export const fetchJson = async( url, options ) => { + try { + const response = await fetch( url, options ); + if ( ! response.ok ) { + throw getResponseError( response ); + } + return response.json(); + } catch ( e ) { + return Promise.reject( e ); + } +}; diff --git a/packages/js/src/dashboard/fetch/get-response-error.js b/packages/js/src/dashboard/fetch/get-response-error.js new file mode 100644 index 00000000000..c526d2b5ffa --- /dev/null +++ b/packages/js/src/dashboard/fetch/get-response-error.js @@ -0,0 +1,14 @@ +import { TimeoutError } from "./timeout-error"; + +/** + * @param {Response} response The response. + * @returns {Error} The error that corresponds to the response. + */ +export const getResponseError = ( response ) => { + switch ( response.status ) { + case 408: + return new TimeoutError( "request timed out" ); + default: + return new Error( "not ok" ); + } +}; diff --git a/packages/js/src/dashboard/fetch/timeout-error.js b/packages/js/src/dashboard/fetch/timeout-error.js new file mode 100644 index 00000000000..4d6230e160e --- /dev/null +++ b/packages/js/src/dashboard/fetch/timeout-error.js @@ -0,0 +1,12 @@ +/** + * Represents a timeout error. + */ +export class TimeoutError extends Error { + /** + * @param {string} message The error message. + */ + constructor( message ) { + super( message ); + this.name = "TimeoutError"; + } +} diff --git a/packages/js/src/dashboard/fetch/use-fetch.js b/packages/js/src/dashboard/fetch/use-fetch.js new file mode 100644 index 00000000000..8c78ae34ce1 --- /dev/null +++ b/packages/js/src/dashboard/fetch/use-fetch.js @@ -0,0 +1,68 @@ +import { useCallback, useEffect, useRef, useState } from "@wordpress/element"; +import { debounce, identity } from "lodash"; +import { FETCH_DELAY } from "../../shared-admin/constants"; +import { fetchJson } from "./fetch-json"; + +/** + * @typedef {Object} FetchResult + * @property {boolean} isPending Whether the fetch is pending. + * @property {Error?} error The error, if any. + * @property {any?} data The data, if any. + */ + +/** + * @typedef {function} FetchFunction + * @param {string|URL} url The URL to fetch from. + * @param {RequestInit} options The request options. + * @returns {Promise} The promise of a result, or an error. + */ + +/** + * @param {any[]} dependencies The dependencies for the fetch. + * @param {string|URL} url The URL to fetch from. + * @param {RequestInit} options The request options. + * @param {function(any): any} [prepareData] Transforms the data before "storage". + * @param {FetchFunction} [doFetch] Fetches the data. Defaults to `fetchJson`. + * @param {number} [fetchDelay] Debounce delay for fetching. Defaults to `FETCH_DELAY`. + * @returns {FetchResult} The fetch result. + */ +export const useFetch = ( { dependencies, url, options, prepareData = identity, doFetch = fetchJson, fetchDelay = FETCH_DELAY } ) => { + const [ isPending, setIsPending ] = useState( true ); + const [ error, setError ] = useState(); + const [ data, setData ] = useState(); + /** @type {MutableRefObject} */ + const controller = useRef(); + + // This needs to be wrapped including setting the state, because the debounce return messes with the timing/events. + const handleFetch = useCallback( debounce( ( ...args ) => { + doFetch( ...args ) + .then( ( result ) => { + setData( prepareData( result ) ); + setError( undefined ); // eslint-disable-line no-undefined + } ) + .catch( ( e ) => { + // Ignore abort errors, because they are expected. + if ( e?.name !== "AbortError" ) { + setError( e ); + } + } ) + .finally( () => { + setIsPending( false ); + } ); + }, fetchDelay ), [] ); + + useEffect( () => { + setIsPending( true ); + controller.current?.abort(); + controller.current = new AbortController(); + handleFetch( url, { signal: controller.current.signal, ...options } ); + + return () => controller.current?.abort(); + }, dependencies ); + + return { + data, + error, + isPending, + }; +}; diff --git a/packages/js/src/dashboard/index.js b/packages/js/src/dashboard/index.js new file mode 100644 index 00000000000..5b23f115a4b --- /dev/null +++ b/packages/js/src/dashboard/index.js @@ -0,0 +1,56 @@ +export { Dashboard } from "./components/dashboard"; + +/** + * @typedef {Object} Taxonomy A taxonomy. + * @property {string} name The unique identifier. + * @property {string} label The user-facing label. + * @property {Object} links The links. + * @property {string} [links.search] The search link, might not exist. + */ + +/** + * @typedef {Object} ContentType A content type. + * @property {string} name The unique identifier. + * @property {string} label The user-facing label. + * @property {Taxonomy|null} taxonomy The (main) taxonomy or null. + */ + +/** + * @typedef {Object} Term A term. + * @property {string} name The unique identifier. + * @property {string} label The user-facing label. + */ + +/** + * @typedef {"seo"|"readability"} AnalysisType The analysis type. + */ + +/** + * @typedef {"ok"|"good"|"bad"|"notAnalyzed"} ScoreType The score type. + */ + +/** + * @typedef {Object} Score A score. + * @property {ScoreType} name The name of the score. + * @property {number} amount The amount of content for this score. + * @property {Object} links The links. + * @property {string} [links.view] The view link, might not exist. + */ + +/** + * @typedef {Object} Features Whether features are enabled. + * @property {boolean} indexables Whether indexables are enabled. + * @property {boolean} seoAnalysis Whether SEO analysis is enabled. + * @property {boolean} readabilityAnalysis Whether readability analysis is enabled. + */ + +/** + * @typedef {Object} Endpoints The endpoints. + * @property {string} seoScores The endpoint for SEO scores. + * @property {string} readabilityScores The endpoint for readability scores. + */ + +/** + * @typedef {Object} Links The links. + * @property {string} contentAnalysis The content analysis information link. + */ diff --git a/packages/js/src/dashboard/scores/components/content-status-description.js b/packages/js/src/dashboard/scores/components/content-status-description.js new file mode 100644 index 00000000000..8f0c5000a37 --- /dev/null +++ b/packages/js/src/dashboard/scores/components/content-status-description.js @@ -0,0 +1,17 @@ +import { maxBy } from "lodash"; + +/** + * @type {import("../index").Score} Score + * @type {import("../index").ScoreType} ScoreType + */ + +/** + * @param {Score[]} scores The SEO scores. + * @param {Object.} descriptions The descriptions. + * @returns {JSX.Element} The element. + */ +export const ContentStatusDescription = ( { scores, descriptions } ) => { + const maxScore = maxBy( scores, "amount" ); + + return

{ descriptions[ maxScore?.name ] || "" }

; +}; diff --git a/packages/js/src/dashboard/scores/components/content-type-filter.js b/packages/js/src/dashboard/scores/components/content-type-filter.js new file mode 100644 index 00000000000..5e03d00a39c --- /dev/null +++ b/packages/js/src/dashboard/scores/components/content-type-filter.js @@ -0,0 +1,63 @@ +import { useCallback, useState } from "@wordpress/element"; +import { __ } from "@wordpress/i18n"; +import { AutocompleteField } from "@yoast/ui-library"; +import { replace, unescape } from "lodash"; + +/** + * @type {import("../index").ContentType} ContentType + */ + + +/** + * The regex to find a single quote. + * @type {RegExp} + */ +const findSingleQuoteRegex = new RegExp( "�?39;", "g" ); + +/** + * Decodes the label to remove HTML entities + * @param {string} encodedString The string to decode. + * @returns {string} The decoded string. + */ +function decodeString( encodedString ) { + return replace( unescape( encodedString ), findSingleQuoteRegex, "'" ); +} + +/** + * @param {string} idSuffix The suffix for the ID. + * @param {ContentType[]} contentTypes The content types. + * @param {ContentType?} selected The selected content type. + * @param {function(ContentType?)} onChange The callback. Expects it changes the `selected` prop. + * @returns {JSX.Element} The element. + */ +export const ContentTypeFilter = ( { idSuffix, contentTypes, selected, onChange } ) => { + const [ filtered, setFiltered ] = useState( () => contentTypes ); + + const handleChange = useCallback( ( value ) => { + onChange( contentTypes.find( ( { name } ) => name === value ) ); + }, [ contentTypes ] ); + const handleQueryChange = useCallback( ( event ) => { + const query = event.target.value.trim().toLowerCase(); + setFiltered( query + ? contentTypes.filter( ( { name, label } ) => label.toLowerCase().includes( query ) || name.toLowerCase().includes( query ) ) + : contentTypes ); + }, [ contentTypes ] ); + + return ( + + { filtered.map( ( { name, label } ) => { + const decodedLabel = decodeString( label ); + return + { decodedLabel } + ; + } ) } + + ); +}; diff --git a/packages/js/src/dashboard/scores/components/score-chart.js b/packages/js/src/dashboard/scores/components/score-chart.js new file mode 100644 index 00000000000..c786f84b8ae --- /dev/null +++ b/packages/js/src/dashboard/scores/components/score-chart.js @@ -0,0 +1,77 @@ +import { SkeletonLoader } from "@yoast/ui-library"; +import { ArcElement, Chart, Tooltip } from "chart.js"; +import classNames from "classnames"; +import { Doughnut } from "react-chartjs-2"; +import { SCORE_META } from "../score-meta"; + +/** + * @type {import("../index").Score} Score + */ + +Chart.register( ArcElement, Tooltip ); + +/** + * @param {Score[]} scores The scores. + * @returns {Object} Parsed chart data. + */ +const transformScoresToGraphData = ( scores ) => ( { + labels: scores.map( ( { name } ) => SCORE_META[ name ].label ), + datasets: [ + { + cutout: "82%", + data: scores.map( ( { amount } ) => amount ), + backgroundColor: scores.map( ( { name } ) => SCORE_META[ name ].hex ), + borderWidth: 0, + offset: 0, + hoverOffset: 5, + spacing: 1, + weight: 1, + animation: { + animateRotate: true, + }, + }, + ], +} ); + +const chartOptions = { + plugins: { + legend: false, + tooltip: { + displayColors: false, + callbacks: { + title: () => "", + label: context => `${ context.label }: ${ context?.formattedValue }`, + }, + }, + }, + layout: { + padding: 5, + }, +}; + +/** + * @param {string} [className] The class name. + * @returns {JSX.Element} The element. + */ +export const ScoreChartSkeletonLoader = ( { className } ) => ( +
+ +
+
+); + +/** + * @param {string} [className] The class name. + * @param {Score[]} scores The scores. + * @returns {JSX.Element} The element. + */ +export const ScoreChart = ( { className, scores } ) => { + return ( +
+ +
+ ); +}; diff --git a/packages/js/src/dashboard/scores/components/score-content.js b/packages/js/src/dashboard/scores/components/score-content.js new file mode 100644 index 00000000000..d6ee49381e4 --- /dev/null +++ b/packages/js/src/dashboard/scores/components/score-content.js @@ -0,0 +1,55 @@ +import { SkeletonLoader } from "@yoast/ui-library"; +import { ContentStatusDescription } from "./content-status-description"; +import { ScoreChart, ScoreChartSkeletonLoader } from "./score-chart"; +import { ScoreList, ScoreListSkeletonLoader } from "./score-list"; + +/** + * @type {import("../index").Score} Score + * @type {import("../index").ScoreType} ScoreType + */ + +/** + * @type {{container: string, list: string, chart: string}} + */ +const CLASSNAMES = { + container: "yst-flex yst-flex-col @md:yst-flex-row yst-gap-12 yst-mt-6", + list: "yst-grow", + // Calculation: (line-height 1rem + py 0.375rem * 2) * 4 + (spacing 0.75rem * 2 + border 1px ) * 3 = 11.5rem + 3px. + chart: "yst-w-[calc(11.5rem+3px)] yst-aspect-square", +}; + +/** + * @returns {JSX.Element} The element. + */ +const ScoreContentSkeletonLoader = () => ( + <> +   +
+ + +
+ +); + +/** + * @param {Score[]} [scores=[]] The scores. + * @param {boolean} isLoading Whether the scores are still loading. + * @param {Object.} descriptions The descriptions. + * @param {string} idSuffix The suffix for the IDs. + * @returns {JSX.Element} The element. + */ +export const ScoreContent = ( { scores = [], isLoading, descriptions, idSuffix } ) => { + if ( isLoading ) { + return ; + } + + return ( + <> + +
+ { scores && } + { scores && } +
+ + ); +}; diff --git a/packages/js/src/dashboard/scores/components/score-list.js b/packages/js/src/dashboard/scores/components/score-list.js new file mode 100644 index 00000000000..4c7f6b94b15 --- /dev/null +++ b/packages/js/src/dashboard/scores/components/score-list.js @@ -0,0 +1,100 @@ +import { Badge, Button, Label, SkeletonLoader, TooltipContainer, TooltipTrigger, TooltipWithContext } from "@yoast/ui-library"; +import classNames from "classnames"; +import { SCORE_META } from "../score-meta"; + +/** + * @type {import("../index").Score} Score + */ + +/** + * @type {{listItem: string, score: string}} + */ +const CLASSNAMES = { + listItem: "yst-flex yst-items-center yst-py-3 first:yst-pt-0 last:yst-pb-0 yst-border-b last:yst-border-b-0", + score: "yst-shrink-0 yst-w-3 yst-aspect-square yst-rounded-full", + label: "yst-ml-3 yst-mr-2", +}; + +/** + * @param {string} [className] The class name for the UL. + * @returns {JSX.Element} The element. + */ +export const ScoreListSkeletonLoader = ( { className } ) => ( +
    + { Object.entries( SCORE_META ).map( ( [ name, { label } ] ) => ( +
  • + + { label } + 1 + View +
  • + ) ) } +
+); + +/** + * @param {Score} score The score. + * @returns {JSX.Element} The element. + */ +const ScoreListItemContent = ( { score } ) => ( + <> + + + { score.amount } + +); + +/** + * @param {Score} score The score. + * @param {string} idSuffix The suffix for the IDs. + * @returns {JSX.Element} The element. + */ +const ScoreListItemContentWithTooltip = ( { score, idSuffix } ) => { + const id = `tooltip--${ idSuffix }__${ score.name }`; + + return ( + + + + + + { SCORE_META[ score.name ].tooltip } + + + ); +}; + +/** + * @param {Score} score The score. + * @param {string} idSuffix The suffix for the IDs. + * @returns {JSX.Element} The element. + */ +const ScoreListItem = ( { score, idSuffix } ) => { + const Content = SCORE_META[ score.name ].tooltip ? ScoreListItemContentWithTooltip : ScoreListItemContent; + + return ( +
  • + + { score.links.view && ( + + ) } +
  • + ); +}; + +/** + * @param {string} [className] The class name for the UL. + * @param {Score[]} scores The scores. + * @param {string} idSuffix The suffix for the IDs. + * @returns {JSX.Element} The element. + */ +export const ScoreList = ( { className, scores, idSuffix } ) => ( +
      + { scores.map( ( score ) => ) } +
    +); diff --git a/packages/js/src/dashboard/scores/components/scores.js b/packages/js/src/dashboard/scores/components/scores.js new file mode 100644 index 00000000000..0fe90097617 --- /dev/null +++ b/packages/js/src/dashboard/scores/components/scores.js @@ -0,0 +1,146 @@ +import { createInterpolateElement, useEffect, useState } from "@wordpress/element"; +import { __, sprintf } from "@wordpress/i18n"; +import { Alert, Link, Paper, Title } from "@yoast/ui-library"; +import { useFetch } from "../../fetch/use-fetch"; +import { SCORE_DESCRIPTIONS } from "../score-meta"; +import { ContentTypeFilter } from "./content-type-filter"; +import { ScoreContent } from "./score-content"; +import { TermFilter } from "./term-filter"; + +/** + * @type {import("../index").ContentType} ContentType + * @type {import("../index").Taxonomy} Taxonomy + * @type {import("../index").Term} Term + * @type {import("../index").AnalysisType} AnalysisType + */ + +/** + * @param {string|URL} endpoint The endpoint. + * @param {ContentType} contentType The content type. + * @param {Term?} [term] The term. + * @returns {URL} The URL to get scores. + */ +const createScoresUrl = ( endpoint, contentType, term ) => { + const url = new URL( endpoint ); + + url.searchParams.set( "contentType", contentType.name ); + + if ( contentType.taxonomy?.name && term?.name ) { + url.searchParams.set( "taxonomy", contentType.taxonomy.name ); + url.searchParams.set( "term", term.name ); + } + + return url; +}; + +// Added dummy space as content to prevent children prop warnings in the console. +const supportLink = ; + +const TimeoutErrorMessage = createInterpolateElement( + sprintf( + /* translators: %1$s and %2$s expand to an opening/closing tag for a link to the support page. */ + __( "A timeout occurred, possibly due to a large number of posts or terms. In case you need further help, please take a look at our %1$sSupport page%2$s.", "wordpress-seo" ), + "", + "" + ), + { + supportLink, + } +); +const OtherErrorMessage = createInterpolateElement( + sprintf( + /* translators: %1$s and %2$s expand to an opening/closing tag for a link to the support page. */ + __( "Something went wrong. In case you need further help, please take a look at our %1$sSupport page%2$s.", "wordpress-seo" ), + "", + "" + ), + { + supportLink, + } +); + +/** + * @param {Error?} [error] The error. + * @returns {JSX.Element} The element. + */ +const ErrorAlert = ( { error } ) => { + if ( ! error ) { + return null; + } + return ( + + { error?.name === "TimeoutError" + ? TimeoutErrorMessage + : OtherErrorMessage + } + + ); +}; + +/** + * @param {AnalysisType} analysisType The analysis type. Either "seo" or "readability". + * @param {ContentType[]} contentTypes The content types. May not be empty. + * @param {string} endpoint The endpoint or base URL. + * @param {Object} headers The headers to send with the request. + * @returns {JSX.Element} The element. + */ +export const Scores = ( { analysisType, contentTypes, endpoint, headers } ) => { + const [ selectedContentType, setSelectedContentType ] = useState( contentTypes[ 0 ] ); + const [ selectedTerm, setSelectedTerm ] = useState(); + + const { data: scores, error, isPending } = useFetch( { + dependencies: [ selectedContentType.name, selectedContentType?.taxonomy, selectedTerm?.name ], + url: createScoresUrl( endpoint, selectedContentType, selectedTerm ), + options: { + headers: { + "Content-Type": "application/json", + ...headers, + }, + }, + fetchDelay: 0, + prepareData: ( data ) => data?.scores, + } ); + + useEffect( () => { + // Reset the selected term when the selected content type changes. + setSelectedTerm( undefined ); // eslint-disable-line no-undefined + }, [ selectedContentType.name ] ); + + return ( + + + { analysisType === "readability" + ? __( "Readability scores", "wordpress-seo" ) + : __( "SEO scores", "wordpress-seo" ) + } + +
    + + { selectedContentType.taxonomy && selectedContentType.taxonomy?.links?.search && + + } +
    +
    + + { ! error && ( + + ) } +
    +
    + ); +}; diff --git a/packages/js/src/dashboard/scores/components/term-filter.js b/packages/js/src/dashboard/scores/components/term-filter.js new file mode 100644 index 00000000000..6833ff2787b --- /dev/null +++ b/packages/js/src/dashboard/scores/components/term-filter.js @@ -0,0 +1,106 @@ +import { useCallback, useState } from "@wordpress/element"; +import { __ } from "@wordpress/i18n"; +import { AutocompleteField, Spinner } from "@yoast/ui-library"; +import { unescape } from "lodash"; +import { useFetch } from "../../fetch/use-fetch"; + +/** + * @type {import("../index").Taxonomy} Taxonomy + * @type {import("../index").Term} Term + */ + +/** + * @param {string|URL} endpoint The URL to fetch from. + * @param {string} query The query. + * @returns {URL} The URL to query for the terms. + */ +const createQueryUrl = ( endpoint, query ) => { + const url = new URL( endpoint ); + + url.searchParams.set( "search", query ); + url.searchParams.set( "_fields", [ "id", "name" ] ); + + return url; +}; + +/** + * @param {{id: number, name: string}} term The term from the response. + * @returns {Term} The transformed term for internal usage. + */ +const transformTerm = ( term ) => ( { name: String( term.id ), label: unescape( term.name ) } ); + +/** + * Renders either a list of terms or a message that nothing was found. + * @param {Term[]} terms The terms. + * @returns {JSX.Element} The element. + */ +const Content = ( { terms } ) => terms.length === 0 + ? ( +
    + { __( "Nothing found", "wordpress-seo" ) } +
    + ) + : terms.map( ( { name, label } ) => ( + + { label } + + ) ) +; + +/** + * @param {string} idSuffix The suffix for the ID. + * @param {Taxonomy} taxonomy The taxonomy. + * @param {Term?} selected The selected term. + * @param {function(Term?)} onChange The callback. Expects it changes the `selected` prop. + * @returns {JSX.Element} The element. + */ +export const TermFilter = ( { idSuffix, taxonomy, selected, onChange } ) => { + const [ query, setQuery ] = useState( "" ); + const { data: terms = [], error, isPending } = useFetch( { + dependencies: [ taxonomy.links.search, query ], + url: createQueryUrl( taxonomy.links.search, query ), + options: { + headers: { + "Content-Type": "application/json", + }, + }, + prepareData: ( result ) => result.map( transformTerm ), + } ); + + const handleChange = useCallback( ( value ) => { + if ( value === null ) { + // User indicated they want to clear the selection. + setQuery( "" ); + } + onChange( terms.find( ( { name } ) => name === value ) ); + }, [ terms ] ); + + const handleQueryChange = useCallback( ( event ) => { + setQuery( event?.target?.value?.trim()?.toLowerCase() || "" ); + }, [] ); + + return ( + + { isPending && ( +
    + +
    + ) } + { ! isPending && } +
    + ); +}; diff --git a/packages/js/src/dashboard/scores/score-meta.js b/packages/js/src/dashboard/scores/score-meta.js new file mode 100644 index 00000000000..811e2f2bdd9 --- /dev/null +++ b/packages/js/src/dashboard/scores/score-meta.js @@ -0,0 +1,51 @@ +import { __ } from "@wordpress/i18n"; + +/** + * @type {import("../index").AnalysisType} AnalysisType + * @type {import("../index").ScoreType} ScoreType + */ + +/** + * @type {Object.} The meta data. + */ +export const SCORE_META = { + good: { + label: __( "Good", "wordpress-seo" ), + color: "yst-bg-analysis-good", + hex: "#7ad03a", + }, + ok: { + label: __( "OK", "wordpress-seo" ), + color: "yst-bg-analysis-ok", + hex: "#ee7c1b", + }, + bad: { + label: __( "Needs improvement", "wordpress-seo" ), + color: "yst-bg-analysis-bad", + hex: "#dc3232", + }, + notAnalyzed: { + label: __( "Not analyzed", "wordpress-seo" ), + color: "yst-bg-analysis-na", + hex: "#cbd5e1", + tooltip: __( "We haven’t analyzed this content yet. Please open it in your editor, ensure a focus keyphrase is entered, and save it so we can start the analysis.", "wordpress-seo" ), + }, +}; + +/** + * @type {Object.>} The descriptions. + */ +export const SCORE_DESCRIPTIONS = { + seo: { + good: __( "Most of your content has a good SEO score. Well done!", "wordpress-seo" ), + ok: __( "Your content has an average SEO score. Time to find opportunities for improvement!", "wordpress-seo" ), + bad: __( "Some of your content could use a little extra care. Take a look and start improving!", "wordpress-seo" ), + notAnalyzed: __( "Some of your content hasn't been analyzed yet. Please open it in your editor, ensure a focus keyphrase is entered, and save it so we can start the analysis.", "wordpress-seo" ), + }, + readability: { + good: __( "Most of your content has a good readability score. Well done!", "wordpress-seo" ), + ok: __( "Your content has an average readability score. Time to find opportunities for improvement!", "wordpress-seo" ), + bad: __( "Some of your content could use a little extra care. Take a look and start improving!", "wordpress-seo" ), + notAnalyzed: __( "Some of your content hasn't been analyzed yet. Please open it and save it in your editor so we can start the analysis.", "wordpress-seo" ), + }, +}; diff --git a/packages/js/src/general/app.js b/packages/js/src/general/app.js index 69ae0efea4f..387ec046419 100644 --- a/packages/js/src/general/app.js +++ b/packages/js/src/general/app.js @@ -1,7 +1,7 @@ /* eslint-disable complexity */ import { Transition } from "@headlessui/react"; -import { AdjustmentsIcon, BellIcon } from "@heroicons/react/outline"; +import { AdjustmentsIcon, BellIcon, ChartPieIcon } from "@heroicons/react/outline"; import { useDispatch, useSelect } from "@wordpress/data"; import { useCallback, useEffect, useMemo } from "@wordpress/element"; import { __ } from "@wordpress/i18n"; @@ -16,6 +16,7 @@ import { MenuItemLink, YoastLogo } from "../shared-admin/components"; import { Notice } from "./components"; import { STORE_NAME } from "./constants"; import { useNotificationCountSync, useSelectGeneralPage } from "./hooks"; +import { ROUTES } from "./routes"; /** * @param {string} [idSuffix] Extra id suffix. Can prevent double IDs on the page. @@ -36,28 +37,35 @@ const Menu = ( { idSuffix = "" } ) => { -
    -
      - - - { __( "Alert center", "wordpress-seo" ) } - } - idSuffix={ idSuffix } - className="yst-gap-3" - /> - - - { __( "First-time configuration", "wordpress-seo" ) } - } - idSuffix={ idSuffix } - className="yst-gap-3" - /> -
    -
    +
      + + + { __( "Dashboard", "wordpress-seo" ) } + } + idSuffix={ idSuffix } + className="yst-gap-3" + /> + + + { __( "Alert center", "wordpress-seo" ) } + } + idSuffix={ idSuffix } + className="yst-gap-3" + /> + + + { __( "First-time configuration", "wordpress-seo" ) } + } + idSuffix={ idSuffix } + className="yst-gap-3" + /> +
    ; }; Menu.propTypes = { @@ -118,7 +126,7 @@ const App = () => { enterFrom="yst-opacity-0" enterTo="yst-opacity-100" > - { pathname !== "/first-time-configuration" &&
    + { pathname !== ROUTES.firstTimeConfiguration &&
    { shouldShowWebinarPromotionNotificationInDashboard( STORE_NAME ) && } diff --git a/packages/js/src/general/components/connected-premium-upsell-list.js b/packages/js/src/general/components/connected-premium-upsell-list.js new file mode 100644 index 00000000000..cce55eb5bd4 --- /dev/null +++ b/packages/js/src/general/components/connected-premium-upsell-list.js @@ -0,0 +1,24 @@ +import { useSelect } from "@wordpress/data"; +import { PremiumUpsellList } from "../../shared-admin/components"; +import { STORE_NAME } from "../constants"; +import { useSelectGeneralPage } from "../hooks"; + +/** + * @returns {JSX.Element|null} The premium upsell list or null if not applicable. + */ +export const ConnectedPremiumUpsellList = () => { + const isPremium = useSelectGeneralPage( "selectPreference", [], "isPremium" ); + const premiumUpsellConfig = useSelectGeneralPage( "selectUpsellSettingsAsProps" ); + const { isPromotionActive } = useSelect( STORE_NAME ); + const premiumLinkList = useSelectGeneralPage( "selectLink", [], "https://yoa.st/17h" ); + + if ( isPremium ) { + return null; + } + return ; +}; + diff --git a/packages/js/src/general/components/sidebar-layout.js b/packages/js/src/general/components/sidebar-layout.js new file mode 100644 index 00000000000..854ef4b3cce --- /dev/null +++ b/packages/js/src/general/components/sidebar-layout.js @@ -0,0 +1,44 @@ +import { useSelect } from "@wordpress/data"; +import classNames from "classnames"; +import PropTypes from "prop-types"; +import { SidebarRecommendations } from "../../shared-admin/components"; +import { STORE_NAME } from "../constants"; +import { useSelectGeneralPage } from "../hooks"; + +/** + * @param {string} [contentClassName] Extra class name for the children container. + * @param {JSX.node} children The children. + * @returns {JSX.Element} The element. + */ +export const SidebarLayout = ( { contentClassName, children } ) => { + const isPremium = useSelectGeneralPage( "selectPreference", [], "isPremium" ); + const premiumLinkSidebar = useSelectGeneralPage( "selectLink", [], "https://yoa.st/jj" ); + const premiumUpsellConfig = useSelectGeneralPage( "selectUpsellSettingsAsProps" ); + const academyLink = useSelectGeneralPage( "selectLink", [], "https://yoa.st/3t6" ); + const { isPromotionActive } = useSelect( STORE_NAME ); + + return ( +
    +
    + { children } +
    + { ! isPremium && +
    +
    + +
    +
    + } +
    + ); +}; + +SidebarLayout.propTypes = { + contentClassName: PropTypes.string, + children: PropTypes.node, +}; diff --git a/packages/js/src/general/initialize.js b/packages/js/src/general/initialize.js index 7a5399cf1b8..c8f93a57511 100644 --- a/packages/js/src/general/initialize.js +++ b/packages/js/src/general/initialize.js @@ -5,14 +5,24 @@ import { render } from "@wordpress/element"; import { Root } from "@yoast/ui-library"; import { get } from "lodash"; import { createHashRouter, createRoutesFromElements, Navigate, Route, RouterProvider } from "react-router-dom"; +import { Dashboard } from "../dashboard"; import { LINK_PARAMS_NAME } from "../shared-admin/store"; -import { FTC_NAME } from "./store/first-time-configuration"; import App from "./app"; import { RouteErrorFallback } from "./components"; +import { ConnectedPremiumUpsellList } from "./components/connected-premium-upsell-list"; +import { SidebarLayout } from "./components/sidebar-layout"; import { STORE_NAME } from "./constants"; -import { AlertCenter, FirstTimeConfiguration } from "./routes"; +import { AlertCenter, FirstTimeConfiguration, ROUTES } from "./routes"; import registerStore from "./store"; import { ALERT_CENTER_NAME } from "./store/alert-center"; +import { FTC_NAME } from "./store/first-time-configuration"; + +/** + * @type {import("../index").ContentType} ContentType + * @type {import("../index").Features} Features + * @type {import("../index").Links} Links + * @type {import("../index").Endpoints} Endpoints + */ domReady( () => { const root = document.getElementById( "yoast-seo-general" ); @@ -31,19 +41,66 @@ domReady( () => { } ); const isRtl = select( STORE_NAME ).selectPreference( "isRtl", false ); + /** @type {ContentType[]} */ + const contentTypes = get( window, "wpseoScriptData.dashboard.contentTypes", [] ); + /** @type {string} */ + const userName = get( window, "wpseoScriptData.dashboard.displayName", "User" ); + /** @type {Features} */ + const features = { + indexables: get( window, "wpseoScriptData.dashboard.indexablesEnabled", false ), + seoAnalysis: get( window, "wpseoScriptData.dashboard.enabledAnalysisFeatures.keyphraseAnalysis", false ), + readabilityAnalysis: get( window, "wpseoScriptData.dashboard.enabledAnalysisFeatures.readabilityAnalysis", false ), + }; + + /** @type {Endpoints} */ + const endpoints = { + seoScores: get( window, "wpseoScriptData.dashboard.endpoints.seoScores", "" ), + readabilityScores: get( window, "wpseoScriptData.dashboard.endpoints.readabilityScores", "" ), + }; + /** @type {Object} */ + const headers = { + "X-Wp-Nonce": get( window, "wpseoScriptData.dashboard.nonce", "" ), + }; + + /** @type {{contentAnalysis: string}} */ + const links = { + contentAnalysis: select( STORE_NAME ).selectLink( "https://yoa.st/content-analysis-tool" ), + }; + const router = createHashRouter( createRoutesFromElements( } errorElement={ }> - } errorElement={ } /> - } errorElement={ } /> + + + + + } + errorElement={ } + /> + } + errorElement={ } + /> + } errorElement={ } /> { /** - * Fallback route: redirect to the root (alert center). + * Fallback route: redirect to the dashboard. * A redirect is used to support the activePath in the menu. E.g. `pathname` matches exactly. * It replaces the current path to not introduce invalid history in the browser (that would just redirect again). */ } - } /> + } /> ) ); diff --git a/packages/js/src/general/routes/alert-center.js b/packages/js/src/general/routes/alert-center.js index 6dc12e5aa21..f1631af5a55 100644 --- a/packages/js/src/general/routes/alert-center.js +++ b/packages/js/src/general/routes/alert-center.js @@ -1,24 +1,13 @@ -import { useSelect } from "@wordpress/data"; import { __ } from "@wordpress/i18n"; import { Paper, Title } from "@yoast/ui-library"; -import { PremiumUpsellList, SidebarRecommendations } from "../../shared-admin/components"; import { Notifications, Problems } from "../components"; -import { STORE_NAME } from "../constants"; -import { useSelectGeneralPage } from "../hooks"; /** * @returns {JSX.Element} The general page content placeholder. */ export const AlertCenter = () => { - const isPremium = useSelectGeneralPage( "selectPreference", [], "isPremium" ); - const premiumLinkList = useSelectGeneralPage( "selectLink", [], "https://yoa.st/17h" ); - const premiumLinkSidebar = useSelectGeneralPage( "selectLink", [], "https://yoa.st/jj" ); - const premiumUpsellConfig = useSelectGeneralPage( "selectUpsellSettingsAsProps" ); - const academyLink = useSelectGeneralPage( "selectLink", [], "https://yoa.st/3t6" ); - const { isPromotionActive } = useSelect( STORE_NAME ); - - return
    -
    + return ( + <>
    { __( "Alert center", "wordpress-seo" ) } @@ -31,23 +20,6 @@ export const AlertCenter = () => {
    - { ! isPremium && } -
    - { ! isPremium && -
    -
    - -
    -
    - } -
    ; + + ); }; diff --git a/packages/js/src/general/routes/index.js b/packages/js/src/general/routes/index.js index 0816e934173..78e8320fdc5 100644 --- a/packages/js/src/general/routes/index.js +++ b/packages/js/src/general/routes/index.js @@ -1,2 +1,8 @@ export { FirstTimeConfiguration } from "./first-time-configuration"; export { AlertCenter } from "./alert-center"; + +export const ROUTES = { + dashboard: "/", + alertCenter: "/alert-center", + firstTimeConfiguration: "/first-time-configuration", +}; diff --git a/packages/js/src/settings/components/formik-page-select-field.js b/packages/js/src/settings/components/formik-page-select-field.js index 4e793221587..a7382dcb8bf 100644 --- a/packages/js/src/settings/components/formik-page-select-field.js +++ b/packages/js/src/settings/components/formik-page-select-field.js @@ -7,7 +7,7 @@ import classNames from "classnames"; import { useField } from "formik"; import { debounce, find, isEmpty, map, values } from "lodash"; import PropTypes from "prop-types"; -import { ASYNC_ACTION_STATUS } from "../../shared-admin/constants"; +import { ASYNC_ACTION_STATUS, FETCH_DELAY } from "../../shared-admin/constants"; import { useDispatchSettings, useSelectSettings } from "../hooks"; /** @@ -63,7 +63,7 @@ const FormikPageSelectField = ( { name, id, ...props } ) => { setQueriedPageIds( [] ); setStatus( ASYNC_ACTION_STATUS.error ); } - }, 200 ), [ setQueriedPageIds, setStatus, fetchPages ] ); + }, FETCH_DELAY ), [ setQueriedPageIds, setStatus, fetchPages ] ); const handleChange = useCallback( newValue => { setTouched( true, false ); diff --git a/packages/js/src/settings/components/formik-user-select-field.js b/packages/js/src/settings/components/formik-user-select-field.js index db84b6ea6f3..38df17a0e85 100644 --- a/packages/js/src/settings/components/formik-user-select-field.js +++ b/packages/js/src/settings/components/formik-user-select-field.js @@ -9,7 +9,7 @@ import classNames from "classnames"; import { useField } from "formik"; import { debounce, find, isEmpty, map, values } from "lodash"; import PropTypes from "prop-types"; -import { ASYNC_ACTION_STATUS } from "../../shared-admin/constants"; +import { ASYNC_ACTION_STATUS, FETCH_DELAY } from "../../shared-admin/constants"; import { useDispatchSettings, useSelectSettings } from "../hooks"; let abortController; @@ -80,7 +80,7 @@ const FormikUserSelectField = ( { name, id, className = "", ...props } ) => { console.error( error.message ); } - }, 200 ), [ setQueriedUserIds, addManyUsers, setStatus ] ); + }, FETCH_DELAY ), [ setQueriedUserIds, addManyUsers, setStatus ] ); const handleChange = useCallback( newValue => { setTouched( true, false ); diff --git a/packages/js/src/shared-admin/components/premium-upsell-list.js b/packages/js/src/shared-admin/components/premium-upsell-list.js index 42f19a9eaa3..ecc231bfa3e 100644 --- a/packages/js/src/shared-admin/components/premium-upsell-list.js +++ b/packages/js/src/shared-admin/components/premium-upsell-list.js @@ -1,8 +1,8 @@ -import { noop } from "lodash"; import { ArrowNarrowRightIcon } from "@heroicons/react/outline"; import { createInterpolateElement } from "@wordpress/element"; import { __, sprintf } from "@wordpress/i18n"; -import { Button, Title, Paper } from "@yoast/ui-library"; +import { Button, Paper, Title } from "@yoast/ui-library"; +import { noop } from "lodash"; import PropTypes from "prop-types"; import { getPremiumBenefits } from "../../helpers/get-premium-benefits"; diff --git a/packages/js/src/shared-admin/constants/index.js b/packages/js/src/shared-admin/constants/index.js index 2d2d680b7da..c960bf4c063 100644 --- a/packages/js/src/shared-admin/constants/index.js +++ b/packages/js/src/shared-admin/constants/index.js @@ -19,3 +19,5 @@ export const VIDEO_FLOW = { askPermission: "askPermission", isPlaying: "isPlaying", }; + +export const FETCH_DELAY = 200; diff --git a/packages/tailwindcss-preset/index.js b/packages/tailwindcss-preset/index.js index 5d8cbf8729b..f1bcab23622 100644 --- a/packages/tailwindcss-preset/index.js +++ b/packages/tailwindcss-preset/index.js @@ -31,6 +31,12 @@ module.exports = { 800: "#83084e", 900: "#770045", }, + analysis: { + good: "#7ad03a", + ok: "#ee7c1b", + bad: "#dc3232", + na: "#cbd5e1", + }, }, strokeWidth: { 3: "3px", diff --git a/packages/ui-library/.storybook/style.css b/packages/ui-library/.storybook/style.css index 07724fc8976..498e30b3a16 100644 --- a/packages/ui-library/.storybook/style.css +++ b/packages/ui-library/.storybook/style.css @@ -38,7 +38,7 @@ @import "../src/components/text-field/style.css"; @import "../src/components/textarea-field/style.css"; @import "../src/components/toggle-field/style.css"; - +@import "../src/components/tooltip-container/style.css"; @tailwind base; @tailwind components; diff --git a/packages/ui-library/src/components/tooltip-container/docs/component.md b/packages/ui-library/src/components/tooltip-container/docs/component.md new file mode 100644 index 00000000000..b52340a8690 --- /dev/null +++ b/packages/ui-library/src/components/tooltip-container/docs/component.md @@ -0,0 +1,26 @@ +Tooltips provide contextual information about an element when that owning element receives focus or is hovered over, but are otherwise not visible. They are displayed as small boxes containing a brief label or message. See the Tooltip elements. + +However, to get a fully functioning experience, with regards to accessibility, the Tooltip needs more. The following are the requirements for the Tooltip: +* The Tooltip should be visible when the element that triggers the Tooltip is focused. And it should be hidden when the element is blurred. +* The Tooltip should be visible when the element that triggers the Tooltip is hovered over. And it should be hidden when the element is no longer hovered over. +* The Tooltip should be hidden when the user presses the `Escape` key, regardless of focus or hover. + +That is what this **TooltipContainer**, the **TooltipTrigger** and the **TooltipWithContext** components are for. + +The **TooltipContainer** is the parent component that wraps the TooltipTrigger and the Tooltip components. It manages the visibility of the Tooltip. +* It provides the `isVisible` boolean and `show` and `hide` functions. +* It adds a `keydown` event listener to hide the tooltip when the user presses `Escape`. +* It contains the styling to center and control the tooltip visibility. + +The **TooltipTrigger** wraps the content that should trigger the Tooltip in a focusable element. +* It ensures that the tooltip is shown on focus and mouse enter. +* It adds the `aria-describedby` attribute to associate the tooltip with the trigger. +* It adds the `aria-disabled` attribute to indicate the trigger itself is not actually doing anything. +* It has styling for keyboard focus (`focus-visible`) and none for hover. + +The **TooltipWithContext** wraps the Tooltip element. +* It gets the `isVisible` from the context. +* It hides the Tooltip via the `yst-hidden` className when `isVisible` is false. +* It forwards any props to the Tooltip element. + +**Note**: The tooltip is the same element, so if you want to override styling like `display`. You should add a container inside. diff --git a/packages/ui-library/src/components/tooltip-container/docs/index.js b/packages/ui-library/src/components/tooltip-container/docs/index.js new file mode 100644 index 00000000000..b838fe39bec --- /dev/null +++ b/packages/ui-library/src/components/tooltip-container/docs/index.js @@ -0,0 +1,2 @@ +export { default as component } from "./component.md"; +export { default as withFlex } from "./with-flex.md"; diff --git a/packages/ui-library/src/components/tooltip-container/docs/with-flex.md b/packages/ui-library/src/components/tooltip-container/docs/with-flex.md new file mode 100644 index 00000000000..5d16f795f73 --- /dev/null +++ b/packages/ui-library/src/components/tooltip-container/docs/with-flex.md @@ -0,0 +1,5 @@ +The TooltipWithContext renders the Tooltip element directly. +Meaning that adding style could mean overriding the Tooltip style. + +For example the `display` if you want to use `diplay: flex`. +Here is an example, that solves this by adding a container inside. diff --git a/packages/ui-library/src/components/tooltip-container/index.js b/packages/ui-library/src/components/tooltip-container/index.js new file mode 100644 index 00000000000..e56a884cda6 --- /dev/null +++ b/packages/ui-library/src/components/tooltip-container/index.js @@ -0,0 +1,124 @@ +/* eslint-disable react/require-default-props */ +import classNames from "classnames"; +import { noop } from "lodash"; +import PropTypes from "prop-types"; +import React, { createContext, useContext } from "react"; +import Tooltip from "../../elements/tooltip"; +import { useKeydown, useToggleState } from "../../hooks"; + +/** + * @typedef {Object} TooltipContextValue + * @property {boolean} isVisible Whether the tooltip is visible. + * @property {function} show Show the tooltip. + * @property {function} hide Hide the tooltip. + */ + +/** + * @type {React.Context} + */ +const TooltipContext = createContext( { + isVisible: false, + show: noop, + hide: noop, +} ); + +/** + * @returns {TooltipContextValue} The value of the Tooltip context. + */ +export const useTooltipContext = () => useContext( TooltipContext ); + +/** + * Manages the visibility of the tooltip. + * - It provides the isVisible boolean and show and hide functions. + * - It adds a keydown event listener to hide the tooltip when the user presses Escape. + * - It contains the styling to center and control the tooltip visibility. + * @param {JSX.ElementClass} [as="span"] Base component. + * @param {string} [className] CSS class. + * @param {JSX.node} [children] The tooltip trigger and tooltip. + * @returns {JSX.Element} The element. + */ +export const TooltipContainer = ( { as: Component = "span", className, children } ) => { + const [ isVisible, , , show, hide ] = useToggleState( false ); + + useKeydown( ( event ) => { + if ( event.key === "Escape" ) { + hide(); + } + }, document ); + + return ( + + + { children } + + + ); +}; +TooltipContainer.propTypes = { + as: PropTypes.elementType, + children: PropTypes.node, + className: PropTypes.string, +}; + +/** + * Wraps the content that should trigger the tooltip in a focusable element. + * - It ensures that the tooltip is shown on focus and mouse enter. + * - It adds the aria-describedby attribute to associate the tooltip with the trigger. + * - It adds the aria-disabled attribute to indicate the trigger is not actually doing anything. + * - It has styling for keyboard focus and none for hover. + * @param {string|JSX.node} [as="button"] Base component. Needs to be focusable. + * @param {string} [className] CSS class. + * @param {JSX.node} [children] What the tooltip should center on. + * @param {string} [ariaDescribedby] The ID of the tooltip, so that screen readers can associate the tooltip with the trigger. + * @param {Object} [props] Additional props. + * @returns {JSX.Element} The element. + */ +export const TooltipTrigger = ( { as: Component = "button", className, children, ariaDescribedby, ...props } ) => { + const { show } = useTooltipContext(); + + return ( + + { children } + + ); +}; +TooltipTrigger.propTypes = { + as: PropTypes.elementType, + children: PropTypes.node, + className: PropTypes.string, + ariaDescribedby: PropTypes.string, +}; + +/** + * Wraps the Tooltip element. + * - It gets the `isVisible` from the context. + * - It hides the Tooltip via the `yst-hidden` className when `isVisible` is false. + * - It forwards any props to the Tooltip element. + * @param {string} [className] CSS class. + * @param {JSX.node} [children] What the tooltip should center on. + * @param {Object} [props] Additional props. + * @returns {JSX.Element} The element. + */ +export const TooltipWithContext = ( { className, children, ...props } ) => { + const { isVisible } = useTooltipContext(); + + return ( + + { children } + + ); +}; +TooltipWithContext.propTypes = { + className: PropTypes.string, + children: PropTypes.node, +}; diff --git a/packages/ui-library/src/components/tooltip-container/stories.js b/packages/ui-library/src/components/tooltip-container/stories.js new file mode 100644 index 00000000000..b3b1ad85830 --- /dev/null +++ b/packages/ui-library/src/components/tooltip-container/stories.js @@ -0,0 +1,112 @@ +import React from "react"; +import { InteractiveDocsPage } from "../../../.storybook/interactive-docs-page"; +import { component, withFlex } from "./docs"; +import { TooltipContainer, TooltipTrigger, TooltipWithContext } from "./index"; + +export const Factory = { + parameters: { + controls: { disable: false }, + }, + args: { + children: <> + Element containing a tooltip. + I'm a tooltip + , + }, +}; + +export const Trigger = { + name: "TooltipTrigger", + render: ( args ) => , + parameters: { + controls: { disable: false }, + }, + args: { + children: "Element containing a tooltip.", + ariaDescribedby: "tooltip-trigger", + }, + decorators: [ + ( Story, args ) => ( + + + I'm a tooltip + + ), + ], +}; + +export const WithContext = { + name: "TooltipWithContext", + render: ( args ) => , + parameters: { + controls: { disable: false }, + }, + args: { + id: "tooltip", + children: "I'm a tooltip", + }, + decorators: [ + ( Story, args ) => ( + + Element containing a tooltip. + + + ), + ], +}; + +export const WithFlex = { + name: "With display flex", + render: ( args ) => , + parameters: { + controls: { disable: false }, + docs: { + description: { + story: withFlex, + }, + }, + }, + args: { + id: "tooltip", + children:
    + Row one + Row two +
    , + }, + decorators: [ + ( Story, args ) => ( + + Element containing a tooltip. + + + ), + ], +}; + + +export default { + title: "2) Components/Tooltip Container", + component: TooltipContainer, + argTypes: { + as: { + control: { type: "select" }, + options: [ "span", "div" ], + table: { type: { summary: "span | div" }, defaultValue: { summary: "span" } }, + }, + }, + parameters: { + docs: { + description: { + component, + }, + page: () => , + }, + }, + decorators: [ + ( Story ) => ( +
    + +
    + ), + ], +}; diff --git a/packages/ui-library/src/components/tooltip-container/style.css b/packages/ui-library/src/components/tooltip-container/style.css new file mode 100644 index 00000000000..b14a46b753b --- /dev/null +++ b/packages/ui-library/src/components/tooltip-container/style.css @@ -0,0 +1,25 @@ +@layer components { + .yst-root { + .yst-tooltip-container { + @apply yst-relative; + + --yst-display-tooltip: none; + + &:hover, &:focus-within { + --yst-display-tooltip: inline-block; + } + } + + .yst-tooltip-trigger { + @apply yst-cursor-default + yst-rounded-md + focus:yst-outline-none + focus-visible:yst-ring-primary-500 + focus-visible:yst-border-primary-500 + focus-visible:yst-ring-2 + focus-visible:yst-ring-offset-2 + focus-visible:yst-bg-white + focus-visible:yst-border-opacity-0; + } + } +} diff --git a/packages/ui-library/src/elements/tooltip/style.css b/packages/ui-library/src/elements/tooltip/style.css index ef46ede5e29..71c5f7af228 100644 --- a/packages/ui-library/src/elements/tooltip/style.css +++ b/packages/ui-library/src/elements/tooltip/style.css @@ -1,8 +1,8 @@ @layer components { .yst-root { .yst-tooltip { + display: var(--yst-display-tooltip, inline-block); @apply yst-absolute - yst-inline-block yst-z-10 yst-px-2.5 yst-py-2 @@ -17,6 +17,7 @@ .yst-tooltip--top { @apply yst--translate-x-1/2 + rtl:yst-translate-x-1/2 yst--translate-y-full yst-left-1/2 yst-top-0 @@ -29,6 +30,7 @@ yst-left-1/2 yst-top-full /* Arrow positioned at the bottom of the tooltip */ yst--translate-x-1/2 + rtl:yst-translate-x-1/2 yst-translate-y-0 yst-border-8 yst-border-x-transparent @@ -39,6 +41,7 @@ .yst-tooltip--bottom { @apply yst--translate-x-1/2 + rtl:yst-translate-x-1/2 yst--translate-y-0 yst-left-1/2 yst-top-full @@ -51,6 +54,7 @@ yst-left-1/2 yst-bottom-full /* Arrow positioned at the top of the tooltip */ yst--translate-x-1/2 + rtl:yst-translate-x-1/2 yst-border-8 yst-border-x-transparent yst-border-t-transparent @@ -59,7 +63,7 @@ } .yst-tooltip--right { - @apply yst--translate-x-0 + @apply yst--translate-x-0 yst--translate-y-1/2 yst-left-full yst-top-1/2 diff --git a/packages/ui-library/src/hooks/index.js b/packages/ui-library/src/hooks/index.js index 8add304dbb9..a43e67dcc53 100644 --- a/packages/ui-library/src/hooks/index.js +++ b/packages/ui-library/src/hooks/index.js @@ -1,5 +1,6 @@ export { default as useBeforeUnload } from "./use-before-unload"; export { default as useDescribedBy } from "./use-described-by"; +export { useKeydown } from "./use-keydown"; export { default as usePrevious } from "./use-previous"; export { default as useRootContext } from "./use-root-context"; export { default as useSvgAria } from "./use-svg-aria"; diff --git a/packages/ui-library/src/hooks/readme.md b/packages/ui-library/src/hooks/readme.md index ae4358d9dca..8a78c96d76c 100644 --- a/packages/ui-library/src/hooks/readme.md +++ b/packages/ui-library/src/hooks/readme.md @@ -39,6 +39,30 @@ const Component = () => { return
    ; }; +~~~ + +## useKeydown +The `useKeydown` hook adds and removes a listener to the document' `keydown` event. +The hook accepts a callback function that is called when the event is triggered. +The callback function receives the KeyboardEvent as an argument. + +### Related +* https://developer.mozilla.org/en-US/docs/Web/API/Element/keydown_event +* https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent + +### Usage/Examples +~~~javascript +import { useKeydown } from from "@yoast/ui-library"; + +const Component = () => { + useKeydown( ( event ) => { + if ( event.key === "Escape" ) { + console.log( "Escape key pressed" ); + } + }, document ); + + return
    ; +}; ~~~ diff --git a/packages/ui-library/src/hooks/use-keydown.js b/packages/ui-library/src/hooks/use-keydown.js new file mode 100644 index 00000000000..8e666772597 --- /dev/null +++ b/packages/ui-library/src/hooks/use-keydown.js @@ -0,0 +1,15 @@ +import { useEffect } from "react"; + +/** + * @param {EventListener} onKeydown The keydown event listener. + * @param {EventTarget} eventTarget The target to listen on. E.g. document or window or an element. + * @returns {void} + */ +export const useKeydown = ( onKeydown, eventTarget ) => { + useEffect( () => { + eventTarget.addEventListener( "keydown", onKeydown ); + return () => { + eventTarget.removeEventListener( "keydown", onKeydown ); + }; + }, [ onKeydown ] ); +}; diff --git a/packages/ui-library/src/index.js b/packages/ui-library/src/index.js index b22a5f0aaa0..830ac850945 100644 --- a/packages/ui-library/src/index.js +++ b/packages/ui-library/src/index.js @@ -40,6 +40,7 @@ export { default as TagField } from "./components/tag-field"; export { default as TextField } from "./components/text-field"; export { default as TextareaField } from "./components/textarea-field"; export { default as ToggleField } from "./components/toggle-field"; +export { TooltipContainer, TooltipTrigger, TooltipWithContext, useTooltipContext } from "./components/tooltip-container"; export * from "./hooks"; export * from "./constants"; diff --git a/src/dashboard/application/configuration/dashboard-configuration.php b/src/dashboard/application/configuration/dashboard-configuration.php new file mode 100644 index 00000000000..eae738da4a0 --- /dev/null +++ b/src/dashboard/application/configuration/dashboard-configuration.php @@ -0,0 +1,111 @@ +content_types_repository = $content_types_repository; + $this->indexable_helper = $indexable_helper; + $this->user_helper = $user_helper; + $this->enabled_analysis_features_repository = $enabled_analysis_features_repository; + $this->endpoints_repository = $endpoints_repository; + $this->nonce_repository = $nonce_repository; + } + + /** + * Returns a configuration + * + * @return array> + */ + public function get_configuration(): array { + return [ + 'contentTypes' => $this->content_types_repository->get_content_types(), + 'indexablesEnabled' => $this->indexable_helper->should_index_indexables(), + 'displayName' => $this->user_helper->get_current_user_display_name(), + 'enabledAnalysisFeatures' => $this->enabled_analysis_features_repository->get_features_by_keys( + [ + Readability_Analysis::NAME, + Keyphrase_Analysis::NAME, + ] + )->to_array(), + 'endpoints' => $this->endpoints_repository->get_all_endpoints()->to_array(), + 'nonce' => $this->nonce_repository->get_rest_nonce(), + ]; + } +} diff --git a/src/dashboard/application/content-types/content-types-repository.php b/src/dashboard/application/content-types/content-types-repository.php new file mode 100644 index 00000000000..5c9d443a5cb --- /dev/null +++ b/src/dashboard/application/content-types/content-types-repository.php @@ -0,0 +1,57 @@ +content_types_collector = $content_types_collector; + $this->taxonomies_repository = $taxonomies_repository; + } + + /** + * Returns the content types array. + * + * @return array>>>> The content types array. + */ + public function get_content_types(): array { + $content_types_list = $this->content_types_collector->get_content_types(); + + foreach ( $content_types_list->get() as $content_type ) { + $content_type_taxonomy = $this->taxonomies_repository->get_content_type_taxonomy( $content_type->get_name() ); + $content_type->set_taxonomy( $content_type_taxonomy ); + } + + return $content_types_list->to_array(); + } +} diff --git a/src/dashboard/application/endpoints/endpoints-repository.php b/src/dashboard/application/endpoints/endpoints-repository.php new file mode 100644 index 00000000000..d293197a559 --- /dev/null +++ b/src/dashboard/application/endpoints/endpoints-repository.php @@ -0,0 +1,42 @@ + + */ + private $endpoints; + + /** + * Constructs the repository. + * + * @param Endpoint_Interface ...$endpoints The endpoints to add to the repository. + */ + public function __construct( Endpoint_Interface ...$endpoints ) { + $this->endpoints = $endpoints; + } + + /** + * Creates a list with all endpoints. + * + * @return Endpoint_List The list with all endpoints. + */ + public function get_all_endpoints(): Endpoint_List { + $list = new Endpoint_List(); + foreach ( $this->endpoints as $endpoint ) { + $list->add_endpoint( $endpoint ); + } + + return $list; + } +} diff --git a/src/dashboard/application/filter-pairs/filter-pairs-repository.php b/src/dashboard/application/filter-pairs/filter-pairs-repository.php new file mode 100644 index 00000000000..dc93fd72984 --- /dev/null +++ b/src/dashboard/application/filter-pairs/filter-pairs-repository.php @@ -0,0 +1,59 @@ +taxonomies_collector = $taxonomies_collector; + $this->filter_pairs = $filter_pairs; + } + + /** + * Returns a taxonomy based on a content type, by looking into hardcoded filter pairs. + * + * @param string $content_type The content type. + * + * @return Taxonomy|null The taxonomy filter. + */ + public function get_taxonomy( string $content_type ): ?Taxonomy { + foreach ( $this->filter_pairs as $filter_pair ) { + if ( $filter_pair->get_filtered_content_type() === $content_type ) { + return $this->taxonomies_collector->get_taxonomy( $filter_pair->get_filtering_taxonomy(), $content_type ); + } + } + + return null; + } +} diff --git a/src/dashboard/application/score-results/abstract-score-results-repository.php b/src/dashboard/application/score-results/abstract-score-results-repository.php new file mode 100644 index 00000000000..9d322645f77 --- /dev/null +++ b/src/dashboard/application/score-results/abstract-score-results-repository.php @@ -0,0 +1,73 @@ +current_scores_repository = $current_scores_repository; + } + + /** + * Returns the score results for a content type. + * + * @param Content_Type $content_type The content type. + * @param Taxonomy|null $taxonomy The taxonomy of the term we're filtering for. + * @param int|null $term_id The ID of the term we're filtering for. + * + * @return array>> The scores. + * + * @throws Exception When getting score results from the infrastructure fails. + */ + public function get_score_results( Content_Type $content_type, ?Taxonomy $taxonomy, ?int $term_id ): array { + $score_results = $this->score_results_collector->get_score_results( $this->score_groups, $content_type, $term_id ); + + $current_scores_list = $this->current_scores_repository->get_current_scores( $this->score_groups, $score_results, $content_type, $taxonomy, $term_id ); + $score_result_object = new Score_Result( $current_scores_list, $score_results['query_time'], $score_results['cache_used'] ); + + return $score_result_object->to_array(); + } +} diff --git a/src/dashboard/application/score-results/current-scores-repository.php b/src/dashboard/application/score-results/current-scores-repository.php new file mode 100644 index 00000000000..8475023ba42 --- /dev/null +++ b/src/dashboard/application/score-results/current-scores-repository.php @@ -0,0 +1,76 @@ +score_group_link_collector = $score_group_link_collector; + } + + /** + * Returns the current results. + * + * @param Score_Groups_Interface[] $score_groups The score groups. + * @param array $score_results The score results. + * @param Content_Type $content_type The content type. + * @param Taxonomy|null $taxonomy The taxonomy of the term we're filtering for. + * @param int|null $term_id The ID of the term we're filtering for. + * + * @return array>> The current results. + */ + public function get_current_scores( array $score_groups, array $score_results, Content_Type $content_type, ?Taxonomy $taxonomy, ?int $term_id ): Current_Scores_List { + $current_scores_list = new Current_Scores_List(); + + foreach ( $score_groups as $score_group ) { + $score_name = $score_group->get_name(); + $current_score_links = $this->get_current_score_links( $score_group, $content_type, $taxonomy, $term_id ); + + $current_score = new Current_Score( $score_name, (int) $score_results['scores']->$score_name, $current_score_links ); + $current_scores_list->add( $current_score, $score_group->get_position() ); + } + + return $current_scores_list; + } + + /** + * Returns the links for the current scores of a score group. + * + * @param Score_Groups_Interface $score_group The scoure group. + * @param Content_Type $content_type The content type. + * @param Taxonomy|null $taxonomy The taxonomy of the term we're filtering for. + * @param int|null $term_id The ID of the term we're filtering for. + * + * @return array The current score links. + */ + protected function get_current_score_links( Score_Groups_Interface $score_group, Content_Type $content_type, ?Taxonomy $taxonomy, ?int $term_id ): array { + return [ + 'view' => $this->score_group_link_collector->get_view_link( $score_group, $content_type, $taxonomy, $term_id ), + ]; + } +} diff --git a/src/dashboard/application/score-results/readability-score-results/readability-score-results-repository.php b/src/dashboard/application/score-results/readability-score-results/readability-score-results-repository.php new file mode 100644 index 00000000000..7b1d2f586ed --- /dev/null +++ b/src/dashboard/application/score-results/readability-score-results/readability-score-results-repository.php @@ -0,0 +1,28 @@ +score_results_collector = $readability_score_results_collector; + $this->score_groups = $readability_score_groups; + } +} diff --git a/src/dashboard/application/score-results/seo-score-results/seo-score-results-repository.php b/src/dashboard/application/score-results/seo-score-results/seo-score-results-repository.php new file mode 100644 index 00000000000..851c5ab59de --- /dev/null +++ b/src/dashboard/application/score-results/seo-score-results/seo-score-results-repository.php @@ -0,0 +1,28 @@ +score_results_collector = $seo_score_results_collector; + $this->score_groups = $seo_score_groups; + } +} diff --git a/src/dashboard/application/taxonomies/taxonomies-repository.php b/src/dashboard/application/taxonomies/taxonomies-repository.php new file mode 100644 index 00000000000..6c9c799e868 --- /dev/null +++ b/src/dashboard/application/taxonomies/taxonomies-repository.php @@ -0,0 +1,66 @@ +taxonomies_collector = $taxonomies_collector; + $this->filter_pairs_repository = $filter_pairs_repository; + } + + /** + * Returns the object of the filtering taxonomy of a content type. + * + * @param string $content_type The content type that the taxonomy filters. + * + * @return Taxonomy|null The filtering taxonomy of the content type. + */ + public function get_content_type_taxonomy( string $content_type ) { + // First we check if there's a filter that overrides the filtering taxonomy for this content type. + $taxonomy = $this->taxonomies_collector->get_custom_filtering_taxonomy( $content_type ); + if ( $taxonomy ) { + return $taxonomy; + } + + // Then we check if there is a filter explicitly made for this content type. + $taxonomy = $this->filter_pairs_repository->get_taxonomy( $content_type ); + if ( $taxonomy ) { + return $taxonomy; + } + + // If everything else returned empty, we can always try the fallback taxonomy. + return $this->taxonomies_collector->get_fallback_taxonomy( $content_type ); + } +} diff --git a/src/dashboard/domain/content-types/content-type.php b/src/dashboard/domain/content-types/content-type.php new file mode 100644 index 00000000000..275e40ef5c4 --- /dev/null +++ b/src/dashboard/domain/content-types/content-type.php @@ -0,0 +1,83 @@ +name = $name; + $this->label = $label; + $this->taxonomy = $taxonomy; + } + + /** + * Gets name of the content type. + * + * @return string The name of the content type. + */ + public function get_name(): string { + return $this->name; + } + + /** + * Gets label of the content type. + * + * @return string The label of the content type. + */ + public function get_label(): string { + return $this->label; + } + + /** + * Gets the taxonomy that filters the content type. + * + * @return Taxonomy|null The taxonomy that filters the content type. + */ + public function get_taxonomy(): ?Taxonomy { + return $this->taxonomy; + } + + /** + * Sets the taxonomy that filters the content type. + * + * @param Taxonomy|null $taxonomy The taxonomy that filters the content type. + * + * @return void + */ + public function set_taxonomy( ?Taxonomy $taxonomy ): void { + $this->taxonomy = $taxonomy; + } +} diff --git a/src/dashboard/domain/content-types/content-types-list.php b/src/dashboard/domain/content-types/content-types-list.php new file mode 100644 index 00000000000..ad77c889ab8 --- /dev/null +++ b/src/dashboard/domain/content-types/content-types-list.php @@ -0,0 +1,54 @@ + + */ + private $content_types = []; + + /** + * Adds a content type to the list. + * + * @param Content_Type $content_type The content type to add. + * + * @return void + */ + public function add( Content_Type $content_type ): void { + $this->content_types[ $content_type->get_name() ] = $content_type; + } + + /** + * Returns the content types in the list. + * + * @return array The content types in the list. + */ + public function get(): array { + return $this->content_types; + } + + /** + * Parses the content type list to the expected key value representation. + * + * @return array>>>> The content type list presented as the expected key value representation. + */ + public function to_array(): array { + $array = []; + foreach ( $this->content_types as $content_type ) { + $array[] = [ + 'name' => $content_type->get_name(), + 'label' => $content_type->get_label(), + 'taxonomy' => ( $content_type->get_taxonomy() ) ? $content_type->get_taxonomy()->to_array() : null, + ]; + } + + return $array; + } +} diff --git a/src/dashboard/domain/endpoint/endpoint-interface.php b/src/dashboard/domain/endpoint/endpoint-interface.php new file mode 100644 index 00000000000..ae99c2667e9 --- /dev/null +++ b/src/dashboard/domain/endpoint/endpoint-interface.php @@ -0,0 +1,34 @@ + $endpoints + */ + private $endpoints = []; + + /** + * Adds an endpoint to the list. + * + * @param Endpoint_Interface $endpoint An endpoint. + * + * @return void + */ + public function add_endpoint( Endpoint_Interface $endpoint ): void { + $this->endpoints[] = $endpoint; + } + + /** + * Converts the list to an array. + * + * @return array The array of endpoints. + */ + public function to_array(): array { + $result = []; + foreach ( $this->endpoints as $endpoint ) { + $result[ $endpoint->get_name() ] = $endpoint->get_url(); + } + + return $result; + } +} diff --git a/src/dashboard/domain/filter-pairs/filter-pairs-interface.php b/src/dashboard/domain/filter-pairs/filter-pairs-interface.php new file mode 100644 index 00000000000..bc6ad765d3e --- /dev/null +++ b/src/dashboard/domain/filter-pairs/filter-pairs-interface.php @@ -0,0 +1,23 @@ + + */ + private $links; + + /** + * The constructor. + * + * @param string $name The name of the current score. + * @param int $amount The amount of the current score. + * @param array $links The links of the current score. + */ + public function __construct( string $name, int $amount, ?array $links = null ) { + $this->name = $name; + $this->amount = $amount; + $this->links = $links; + } + + /** + * Gets name of the current score. + * + * @return string The name of the current score. + */ + public function get_name(): string { + return $this->name; + } + + /** + * Gets the amount of the current score. + * + * @return string The amount of the current score. + */ + public function get_amount(): int { + return $this->amount; + } + + /** + * Gets the links of the current score in the expected key value representation. + * + * @return array The links of the current score in the expected key value representation. + */ + public function get_links_to_array(): ?array { + $links = []; + + if ( $this->links === null ) { + return $links; + } + + foreach ( $this->links as $key => $link ) { + if ( $link === null ) { + continue; + } + $links[ $key ] = $link; + } + return $links; + } +} diff --git a/src/dashboard/domain/score-results/current-scores-list.php b/src/dashboard/domain/score-results/current-scores-list.php new file mode 100644 index 00000000000..a4fee60a515 --- /dev/null +++ b/src/dashboard/domain/score-results/current-scores-list.php @@ -0,0 +1,49 @@ +current_scores[ $position ] = $current_score; + } + + /** + * Parses the current score list to the expected key value representation. + * + * @return array>> The score list presented as the expected key value representation. + */ + public function to_array(): array { + $array = []; + + \ksort( $this->current_scores ); + + foreach ( $this->current_scores as $current_score ) { + $array[] = [ + 'name' => $current_score->get_name(), + 'amount' => $current_score->get_amount(), + 'links' => $current_score->get_links_to_array(), + ]; + } + + return $array; + } +} diff --git a/src/dashboard/domain/score-results/score-result.php b/src/dashboard/domain/score-results/score-result.php new file mode 100644 index 00000000000..1a87c6348c7 --- /dev/null +++ b/src/dashboard/domain/score-results/score-result.php @@ -0,0 +1,56 @@ +current_scores_list = $current_scores_list; + $this->query_time = $query_time; + $this->is_cached_used = $is_cached_used; + } + + /** + * Return this object represented by a key value array. + * + * @return array>>|float|bool> Returns the name and if the feature is enabled. + */ + public function to_array(): array { + return [ + 'scores' => $this->current_scores_list->to_array(), + 'queryTime' => $this->query_time, + 'cacheUsed' => $this->is_cached_used, + ]; + } +} diff --git a/src/dashboard/domain/score-results/score-results-not-found-exception.php b/src/dashboard/domain/score-results/score-results-not-found-exception.php new file mode 100644 index 00000000000..594fdb397b8 --- /dev/null +++ b/src/dashboard/domain/score-results/score-results-not-found-exception.php @@ -0,0 +1,18 @@ +name = $name; + $this->label = $label; + $this->rest_url = $rest_url; + } + + /** + * Returns the name of the taxonomy. + * + * @return string The name of the taxonomy. + */ + public function get_name(): string { + return $this->name; + } + + /** + * Parses the taxonomy to the expected key value representation. + * + * @return array> The taxonomy presented as the expected key value representation. + */ + public function to_array(): array { + return [ + 'name' => $this->name, + 'label' => $this->label, + 'links' => [ + 'search' => $this->rest_url, + ], + ]; + } +} diff --git a/src/dashboard/infrastructure/content-types/content-types-collector.php b/src/dashboard/infrastructure/content-types/content-types-collector.php new file mode 100644 index 00000000000..1f3bd64fc35 --- /dev/null +++ b/src/dashboard/infrastructure/content-types/content-types-collector.php @@ -0,0 +1,49 @@ +post_type_helper = $post_type_helper; + } + + /** + * Returns the content types in a list. + * + * @return Content_Types_List The content types in a list. + */ + public function get_content_types(): Content_Types_List { + $content_types_list = new Content_Types_List(); + $post_types = $this->post_type_helper->get_indexable_post_type_objects(); + + foreach ( $post_types as $post_type_object ) { + $content_type = new Content_Type( $post_type_object->name, $post_type_object->label ); + $content_types_list->add( $content_type ); + } + + return $content_types_list; + } +} diff --git a/src/dashboard/infrastructure/endpoints/readability-scores-endpoint.php b/src/dashboard/infrastructure/endpoints/readability-scores-endpoint.php new file mode 100644 index 00000000000..35822aef6c4 --- /dev/null +++ b/src/dashboard/infrastructure/endpoints/readability-scores-endpoint.php @@ -0,0 +1,51 @@ +get_namespace() . $this->get_route() ); + } +} diff --git a/src/dashboard/infrastructure/endpoints/seo-scores-endpoint.php b/src/dashboard/infrastructure/endpoints/seo-scores-endpoint.php new file mode 100644 index 00000000000..e726507fb4e --- /dev/null +++ b/src/dashboard/infrastructure/endpoints/seo-scores-endpoint.php @@ -0,0 +1,51 @@ +get_namespace() . $this->get_route() ); + } +} diff --git a/src/dashboard/infrastructure/nonces/nonce-repository.php b/src/dashboard/infrastructure/nonces/nonce-repository.php new file mode 100644 index 00000000000..162fe086e3a --- /dev/null +++ b/src/dashboard/infrastructure/nonces/nonce-repository.php @@ -0,0 +1,18 @@ + 'publish', + 'post_type' => $content_type->get_name(), + $score_group->get_filter_key() => $score_group->get_filter_value(), + ]; + + if ( $taxonomy === null || $term_id === null ) { + return \add_query_arg( $args, $posts_page ); + } + + $taxonomy_object = \get_taxonomy( $taxonomy->get_name() ); + $query_var = $taxonomy_object->query_var; + + if ( $query_var === false ) { + return null; + } + + $term = \get_term( $term_id ); + $args[ $query_var ] = $term->slug; + + return \add_query_arg( $args, $posts_page ); + } +} diff --git a/src/dashboard/infrastructure/score-results/readability-score-results/readability-score-results-collector.php b/src/dashboard/infrastructure/score-results/readability-score-results/readability-score-results-collector.php new file mode 100644 index 00000000000..dd9920b7908 --- /dev/null +++ b/src/dashboard/infrastructure/score-results/readability-score-results/readability-score-results-collector.php @@ -0,0 +1,152 @@ + The readability score results for a content type. + * + * @throws Score_Results_Not_Found_Exception When the query of getting score results fails. + */ + public function get_score_results( array $readability_score_groups, Content_Type $content_type, ?int $term_id ) { + global $wpdb; + $results = []; + + $content_type_name = $content_type->get_name(); + $transient_name = self::READABILITY_SCORES_TRANSIENT . '_' . $content_type_name . ( ( $term_id === null ) ? '' : '_' . $term_id ); + + $transient = \get_transient( $transient_name ); + if ( $transient !== false ) { + $results['scores'] = \json_decode( $transient, false ); + $results['cache_used'] = true; + $results['query_time'] = 0; + + return $results; + } + + $select = $this->build_select( $readability_score_groups ); + + $replacements = \array_merge( + \array_values( $select['replacements'] ), + [ + Model::get_table_name( 'Indexable' ), + $content_type_name, + ] + ); + + if ( $term_id === null ) { + //phpcs:disable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $replacements is an array with the correct replacements. + //phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $select['fields'] is an array of simple strings with placeholders. + $query = $wpdb->prepare( + " + SELECT {$select['fields']} + FROM %i AS I + WHERE ( I.post_status = 'publish' OR I.post_status IS NULL ) + AND I.object_type = 'post' + AND I.object_sub_type = %s", + $replacements + ); + //phpcs:enable + } + else { + $replacements[] = $wpdb->term_relationships; + $replacements[] = $term_id; + + //phpcs:disable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $replacements is an array with the correct replacements. + //phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $select['fields'] is an array of simple strings with placeholders. + $query = $wpdb->prepare( + " + SELECT {$select['fields']} + FROM %i AS I + WHERE ( I.post_status = 'publish' OR I.post_status IS NULL ) + AND I.object_type = 'post' + AND I.object_sub_type = %s + AND I.object_id IN ( + SELECT object_id + FROM %i + WHERE term_taxonomy_id = %d + )", + $replacements + ); + //phpcs:enable + } + + $start_time = \microtime( true ); + + //phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- $query is prepared above. + //phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery -- Reason: Most performant way. + //phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching -- Reason: No relevant caches. + $current_scores = $wpdb->get_row( $query ); + //phpcs:enable + + if ( $current_scores === null ) { + throw new Score_Results_Not_Found_Exception(); + } + + $end_time = \microtime( true ); + + \set_transient( $transient_name, WPSEO_Utils::format_json_encode( $current_scores ), \MINUTE_IN_SECONDS ); + + $results['scores'] = $current_scores; + $results['cache_used'] = false; + $results['query_time'] = ( $end_time - $start_time ); + return $results; + } + + /** + * Builds the select statement for the readability scores query. + * + * @param Readability_Score_Groups_Interface[] $readability_score_groups All readability score groups. + * + * @return array The select statement for the readability scores query. + */ + private function build_select( array $readability_score_groups ): array { + $select_fields = []; + $select_replacements = []; + + foreach ( $readability_score_groups as $readability_score_group ) { + $min = $readability_score_group->get_min_score(); + $max = $readability_score_group->get_max_score(); + $name = $readability_score_group->get_name(); + + if ( $min === null && $max === null ) { + $select_fields[] = 'COUNT(CASE WHEN I.readability_score = 0 AND I.estimated_reading_time_minutes IS NULL THEN 1 END) AS %i'; + $select_replacements[] = $name; + } + else { + $needs_ert = ( $min === 1 ) ? ' OR (I.readability_score = 0 AND I.estimated_reading_time_minutes IS NOT NULL)' : ''; + $select_fields[] = "COUNT(CASE WHEN ( I.readability_score >= %d AND I.readability_score <= %d ){$needs_ert} THEN 1 END) AS %i"; + $select_replacements[] = $min; + $select_replacements[] = $max; + $select_replacements[] = $name; + } + } + + $select_fields = \implode( ', ', $select_fields ); + + return [ + 'fields' => $select_fields, + 'replacements' => $select_replacements, + ]; + } +} diff --git a/src/dashboard/infrastructure/score-results/score-results-collector-interface.php b/src/dashboard/infrastructure/score-results/score-results-collector-interface.php new file mode 100644 index 00000000000..8ac594168fc --- /dev/null +++ b/src/dashboard/infrastructure/score-results/score-results-collector-interface.php @@ -0,0 +1,24 @@ + The score results for a content type. + */ + public function get_score_results( array $score_groups, Content_Type $content_type, ?int $term_id ); +} diff --git a/src/dashboard/infrastructure/score-results/seo-score-results/seo-score-results-collector.php b/src/dashboard/infrastructure/score-results/seo-score-results/seo-score-results-collector.php new file mode 100644 index 00000000000..3e866cd4a1e --- /dev/null +++ b/src/dashboard/infrastructure/score-results/seo-score-results/seo-score-results-collector.php @@ -0,0 +1,153 @@ + The SEO score results for a content type. + * + * @throws Score_Results_Not_Found_Exception When the query of getting score results fails. + */ + public function get_score_results( array $seo_score_groups, Content_Type $content_type, ?int $term_id ) { + global $wpdb; + $results = []; + + $content_type_name = $content_type->get_name(); + $transient_name = self::SEO_SCORES_TRANSIENT . '_' . $content_type_name . ( ( $term_id === null ) ? '' : '_' . $term_id ); + + $transient = \get_transient( $transient_name ); + if ( $transient !== false ) { + $results['scores'] = \json_decode( $transient, false ); + $results['cache_used'] = true; + $results['query_time'] = 0; + + return $results; + } + + $select = $this->build_select( $seo_score_groups ); + + $replacements = \array_merge( + \array_values( $select['replacements'] ), + [ + Model::get_table_name( 'Indexable' ), + $content_type_name, + ] + ); + + if ( $term_id === null ) { + //phpcs:disable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $replacements is an array with the correct replacements. + //phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $select['fields'] is an array of simple strings with placeholders. + $query = $wpdb->prepare( + " + SELECT {$select['fields']} + FROM %i AS I + WHERE ( I.post_status = 'publish' OR I.post_status IS NULL ) + AND I.object_type = 'post' + AND I.object_sub_type = %s + AND ( I.is_robots_noindex IS NULL OR I.is_robots_noindex <> 1 )", + $replacements + ); + //phpcs:enable + } + else { + $replacements[] = $wpdb->term_relationships; + $replacements[] = $term_id; + + //phpcs:disable WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $replacements is an array with the correct replacements. + //phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $select['fields'] is an array of simple strings with placeholders. + $query = $wpdb->prepare( + " + SELECT {$select['fields']} + FROM %i AS I + WHERE ( I.post_status = 'publish' OR I.post_status IS NULL ) + AND I.object_type IN ('post') + AND I.object_sub_type = %s + AND ( I.is_robots_noindex IS NULL OR I.is_robots_noindex <> 1 ) + AND I.object_id IN ( + SELECT object_id + FROM %i + WHERE term_taxonomy_id = %d + )", + $replacements + ); + //phpcs:enable + } + + $start_time = \microtime( true ); + + //phpcs:disable WordPress.DB.PreparedSQL.NotPrepared -- $query is prepared above. + //phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery -- Reason: Most performant way. + //phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching -- Reason: No relevant caches. + $current_scores = $wpdb->get_row( $query ); + //phpcs:enable + + if ( $current_scores === null ) { + throw new Score_Results_Not_Found_Exception(); + } + + $end_time = \microtime( true ); + + \set_transient( $transient_name, WPSEO_Utils::format_json_encode( $current_scores ), \MINUTE_IN_SECONDS ); + + $results['scores'] = $current_scores; + $results['cache_used'] = false; + $results['query_time'] = ( $end_time - $start_time ); + return $results; + } + + /** + * Builds the select statement for the SEO scores query. + * + * @param SEO_Score_Groups_Interface[] $seo_score_groups All SEO score groups. + * + * @return array The select statement for the SEO scores query. + */ + private function build_select( array $seo_score_groups ): array { + $select_fields = []; + $select_replacements = []; + + foreach ( $seo_score_groups as $seo_score_group ) { + $min = $seo_score_group->get_min_score(); + $max = $seo_score_group->get_max_score(); + $name = $seo_score_group->get_name(); + + if ( $min === null || $max === null ) { + $select_fields[] = 'COUNT(CASE WHEN I.primary_focus_keyword_score = 0 OR I.primary_focus_keyword_score IS NULL THEN 1 END) AS %i'; + $select_replacements[] = $name; + } + else { + $select_fields[] = 'COUNT(CASE WHEN I.primary_focus_keyword_score >= %d AND I.primary_focus_keyword_score <= %d THEN 1 END) AS %i'; + $select_replacements[] = $min; + $select_replacements[] = $max; + $select_replacements[] = $name; + } + } + + $select_fields = \implode( ', ', $select_fields ); + + return [ + 'fields' => $select_fields, + 'replacements' => $select_replacements, + ]; + } +} diff --git a/src/dashboard/infrastructure/taxonomies/taxonomies-collector.php b/src/dashboard/infrastructure/taxonomies/taxonomies-collector.php new file mode 100644 index 00000000000..d6fdc4529b3 --- /dev/null +++ b/src/dashboard/infrastructure/taxonomies/taxonomies-collector.php @@ -0,0 +1,107 @@ +taxonomy_validator = $taxonomy_validator; + } + + /** + * Returns a custom pair of taxonomy/content type, that's been given by users via hooks. + * + * @param string $content_type The content type we're hooking for. + * + * @return Taxonomy|null The hooked filtering taxonomy. + */ + public function get_custom_filtering_taxonomy( string $content_type ) { + /** + * Filter: 'wpseo_{$content_type}_filtering_taxonomy' - Allows overriding which taxonomy filters the content type. + * + * @internal + * + * @param string $filtering_taxonomy The taxonomy that filters the content type. + */ + $filtering_taxonomy = \apply_filters( "wpseo_{$content_type}_filtering_taxonomy", '' ); + if ( $filtering_taxonomy !== '' ) { + $taxonomy = $this->get_taxonomy( $filtering_taxonomy, $content_type ); + + if ( $taxonomy ) { + return $taxonomy; + } + + \_doing_it_wrong( + 'Filter: \'wpseo_{$content_type}_filtering_taxonomy\'', + 'The `wpseo_{$content_type}_filtering_taxonomy` filter should return a public taxonomy, available in REST API, that is associated with that content type.', + 'YoastSEO v24.1' + ); + } + + return null; + } + + /** + * Returns the fallback, WP-native category taxonomy, if it's associated with the specific content type. + * + * @param string $content_type The content type. + * + * @return Taxonomy|null The taxonomy object for the category taxonomy. + */ + public function get_fallback_taxonomy( string $content_type ): ?Taxonomy { + return $this->get_taxonomy( 'category', $content_type ); + } + + /** + * Returns the taxonomy object that filters a specific content type. + * + * @param string $taxonomy_name The name of the taxonomy we're going to build the object for. + * @param string $content_type The content type that the taxonomy object is filtering. + * + * @return Taxonomy|null The taxonomy object. + */ + public function get_taxonomy( string $taxonomy_name, string $content_type ): ?Taxonomy { + $taxonomy = \get_taxonomy( $taxonomy_name ); + + if ( $this->taxonomy_validator->is_valid_taxonomy( $taxonomy, $content_type ) ) { + return new Taxonomy( $taxonomy->name, $taxonomy->label, $this->get_taxonomy_rest_url( $taxonomy ) ); + } + + return null; + } + + /** + * Builds the REST API URL for the taxonomy. + * + * @param WP_Taxonomy $taxonomy The taxonomy we want to build the REST API URL for. + * + * @return string The REST API URL for the taxonomy. + */ + protected function get_taxonomy_rest_url( WP_Taxonomy $taxonomy ): string { + $rest_base = ( $taxonomy->rest_base ) ? $taxonomy->rest_base : $taxonomy->name; + + $rest_namespace = ( $taxonomy->rest_namespace ) ? $taxonomy->rest_namespace : 'wp/v2'; + + return \rest_url( "{$rest_namespace}/{$rest_base}" ); + } +} diff --git a/src/dashboard/infrastructure/taxonomies/taxonomy-validator.php b/src/dashboard/infrastructure/taxonomies/taxonomy-validator.php new file mode 100644 index 00000000000..2f32c18f71e --- /dev/null +++ b/src/dashboard/infrastructure/taxonomies/taxonomy-validator.php @@ -0,0 +1,28 @@ +public + && $taxonomy->show_in_rest + && \in_array( $taxonomy->name, \get_object_taxonomies( $content_type ), true ); + } +} diff --git a/src/dashboard/user-interface/scores/abstract-scores-route.php b/src/dashboard/user-interface/scores/abstract-scores-route.php new file mode 100644 index 00000000000..dd981357a2b --- /dev/null +++ b/src/dashboard/user-interface/scores/abstract-scores-route.php @@ -0,0 +1,272 @@ +content_types_collector = $content_types_collector; + } + + /** + * Sets the repositories. + * + * @required + * + * @param Taxonomies_Repository $taxonomies_repository The taxonomies repository. + * @param Indexable_Repository $indexable_repository The indexable repository. + * + * @return void + */ + public function set_repositories( + Taxonomies_Repository $taxonomies_repository, + Indexable_Repository $indexable_repository + ) { + $this->taxonomies_repository = $taxonomies_repository; + $this->indexable_repository = $indexable_repository; + } + + /** + * Returns the route prefix. + * + * @return string The route prefix. + * + * @throws Exception If the ROUTE_PREFIX constant is not set in the child class. + */ + public static function get_route_prefix() { + $class = static::class; + $prefix = $class::ROUTE_PREFIX; + + if ( $prefix === null ) { + throw new Exception( 'Score route without explicit prefix' ); + } + + return $prefix; + } + + /** + * Registers routes for scores. + * + * @return void + */ + public function register_routes() { + \register_rest_route( + self::ROUTE_NAMESPACE, + $this->get_route_prefix(), + [ + [ + 'methods' => 'GET', + 'callback' => [ $this, 'get_scores' ], + 'permission_callback' => [ $this, 'permission_manage_options' ], + 'args' => [ + 'contentType' => [ + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ], + 'taxonomy' => [ + 'required' => false, + 'type' => 'string', + 'default' => '', + 'sanitize_callback' => 'sanitize_text_field', + ], + 'term' => [ + 'required' => false, + 'type' => 'integer', + 'default' => null, + 'sanitize_callback' => static function ( $param ) { + return \intval( $param ); + }, + ], + ], + ], + ] + ); + } + + /** + * Gets the scores of a specific content type. + * + * @param WP_REST_Request $request The request object. + * + * @return WP_REST_Response The success or failure response. + */ + public function get_scores( WP_REST_Request $request ) { + try { + $content_type = $this->get_content_type( $request['contentType'] ); + $taxonomy = $this->get_taxonomy( $request['taxonomy'], $content_type ); + $term_id = $this->get_validated_term_id( $request['term'], $taxonomy ); + + $results = $this->score_results_repository->get_score_results( $content_type, $taxonomy, $term_id ); + } catch ( Exception $exception ) { + return new WP_REST_Response( + [ + 'error' => $exception->getMessage(), + ], + $exception->getCode() + ); + } + + return new WP_REST_Response( + $results, + 200 + ); + } + + /** + * Gets the content type object. + * + * @param string $content_type The content type. + * + * @return Content_Type|null The content type object. + * + * @throws Exception When the content type is invalid. + */ + protected function get_content_type( string $content_type ): ?Content_Type { + $content_types = $this->content_types_collector->get_content_types()->get(); + + if ( isset( $content_types[ $content_type ] ) && \is_a( $content_types[ $content_type ], Content_Type::class ) ) { + return $content_types[ $content_type ]; + } + + throw new Exception( 'Invalid content type.', 400 ); + } + + /** + * Gets the taxonomy object. + * + * @param string $taxonomy The taxonomy. + * @param Content_Type $content_type The content type that the taxonomy is filtering. + * + * @return Taxonomy|null The taxonomy object. + * + * @throws Exception When the taxonomy is invalid. + */ + protected function get_taxonomy( string $taxonomy, Content_Type $content_type ): ?Taxonomy { + if ( $taxonomy === '' ) { + return null; + } + + $valid_taxonomy = $this->taxonomies_repository->get_content_type_taxonomy( $content_type->get_name() ); + + if ( $valid_taxonomy && $valid_taxonomy->get_name() === $taxonomy ) { + return $valid_taxonomy; + } + + throw new Exception( 'Invalid taxonomy.', 400 ); + } + + /** + * Gets the term ID validated against the given taxonomy. + * + * @param int|null $term_id The term ID to be validated. + * @param Taxonomy|null $taxonomy The taxonomy. + * + * @return bool The validated term ID. + * + * @throws Exception When the term id is invalidated. + */ + protected function get_validated_term_id( ?int $term_id, ?Taxonomy $taxonomy ): ?int { + if ( $term_id !== null && $taxonomy === null ) { + throw new Exception( 'Term needs a provided taxonomy.', 400 ); + } + + if ( $term_id === null && $taxonomy !== null ) { + throw new Exception( 'Taxonomy needs a provided term.', 400 ); + } + + if ( $term_id !== null ) { + $term = \get_term( $term_id ); + if ( ! $term || \is_wp_error( $term ) ) { + throw new Exception( 'Invalid term.', 400 ); + } + + if ( $taxonomy !== null && $term->taxonomy !== $taxonomy->get_name() ) { + throw new Exception( 'Invalid term.', 400 ); + } + } + + return $term_id; + } + + /** + * Permission callback. + * + * @return bool True when user has the 'wpseo_manage_options' capability. + */ + public function permission_manage_options() { + return WPSEO_Capability_Utils::current_user_can( 'wpseo_manage_options' ); + } +} diff --git a/src/dashboard/user-interface/scores/readability-scores-route.php b/src/dashboard/user-interface/scores/readability-scores-route.php new file mode 100644 index 00000000000..7fb174500d8 --- /dev/null +++ b/src/dashboard/user-interface/scores/readability-scores-route.php @@ -0,0 +1,29 @@ +score_results_repository = $readability_score_results_repository; + } +} diff --git a/src/dashboard/user-interface/scores/seo-scores-route.php b/src/dashboard/user-interface/scores/seo-scores-route.php new file mode 100644 index 00000000000..75d41e0cbac --- /dev/null +++ b/src/dashboard/user-interface/scores/seo-scores-route.php @@ -0,0 +1,29 @@ +score_results_repository = $seo_score_results_repository; + } +} diff --git a/src/editors/application/analysis-features/enabled-analysis-features-repository.php b/src/editors/application/analysis-features/enabled-analysis-features-repository.php index 2cec11db2ac..66312a610a7 100644 --- a/src/editors/application/analysis-features/enabled-analysis-features-repository.php +++ b/src/editors/application/analysis-features/enabled-analysis-features-repository.php @@ -50,6 +50,27 @@ public function get_enabled_features(): Analysis_Features_List { $this->enabled_analysis_features->add_feature( $analysis_feature ); } } + return $this->enabled_analysis_features; } + + /** + * Returns the analysis list for the given names. + * + * @param array $feature_names The feature names to include. + * + * @return Analysis_Features_List The analysis list. + */ + public function get_features_by_keys( array $feature_names ): Analysis_Features_List { + $enabled_analysis_features = new Analysis_Features_List(); + + foreach ( $this->plugin_features as $plugin_feature ) { + if ( \in_array( $plugin_feature->get_name(), $feature_names, true ) ) { + $analysis_feature = new Analysis_Feature( $plugin_feature->is_enabled(), $plugin_feature->get_name(), $plugin_feature->get_legacy_key() ); + $enabled_analysis_features->add_feature( $analysis_feature ); + } + } + + return $enabled_analysis_features; + } } diff --git a/src/editors/domain/analysis-features/analysis-features-list.php b/src/editors/domain/analysis-features/analysis-features-list.php index 3fb19d04fec..bc918d25d03 100644 --- a/src/editors/domain/analysis-features/analysis-features-list.php +++ b/src/editors/domain/analysis-features/analysis-features-list.php @@ -35,6 +35,21 @@ public function parse_to_legacy_array(): array { foreach ( $this->features as $feature ) { $array = \array_merge( $array, $feature->to_legacy_array() ); } + + return $array; + } + + /** + * Parses the feature list to an array representation. + * + * @return array The list presented as a key value representation. + */ + public function to_array(): array { + $array = []; + foreach ( $this->features as $feature ) { + $array = \array_merge( $array, $feature->to_array() ); + } + return $array; } } diff --git a/src/editors/framework/cornerstone-content.php b/src/editors/framework/cornerstone-content.php index 8dff716c8c9..e75f3bdbf50 100644 --- a/src/editors/framework/cornerstone-content.php +++ b/src/editors/framework/cornerstone-content.php @@ -10,6 +10,8 @@ */ class Cornerstone_Content implements Analysis_Feature_Interface { + public const NAME = 'cornerstoneContent'; + /** * The options helper. * @@ -41,7 +43,7 @@ public function is_enabled(): bool { * @return string The name. */ public function get_name(): string { - return 'cornerstoneContent'; + return self::NAME; } /** diff --git a/src/editors/framework/inclusive-language-analysis.php b/src/editors/framework/inclusive-language-analysis.php index 4cfbe0254dc..8a8328b58e1 100644 --- a/src/editors/framework/inclusive-language-analysis.php +++ b/src/editors/framework/inclusive-language-analysis.php @@ -12,6 +12,8 @@ */ class Inclusive_Language_Analysis implements Analysis_Feature_Interface { + public const NAME = 'inclusiveLanguageAnalysis'; + /** * The options helper. * @@ -102,7 +104,7 @@ private function is_current_version_supported(): bool { * @return string The name. */ public function get_name(): string { - return 'inclusiveLanguageAnalysis'; + return self::NAME; } /** diff --git a/src/editors/framework/keyphrase-analysis.php b/src/editors/framework/keyphrase-analysis.php index 648639b2e55..287477ec38a 100644 --- a/src/editors/framework/keyphrase-analysis.php +++ b/src/editors/framework/keyphrase-analysis.php @@ -10,6 +10,8 @@ */ class Keyphrase_Analysis implements Analysis_Feature_Interface { + public const NAME = 'keyphraseAnalysis'; + /** * The options helper. * @@ -59,7 +61,7 @@ public function is_globally_enabled(): bool { * @return string The name. */ public function get_name(): string { - return 'keyphraseAnalysis'; + return self::NAME; } /** diff --git a/src/editors/framework/previously-used-keyphrase.php b/src/editors/framework/previously-used-keyphrase.php index 3400f561c6d..e4f3da7288f 100644 --- a/src/editors/framework/previously-used-keyphrase.php +++ b/src/editors/framework/previously-used-keyphrase.php @@ -9,6 +9,8 @@ */ class Previously_Used_Keyphrase implements Analysis_Feature_Interface { + public const NAME = 'previouslyUsedKeyphrase'; + /** * If this analysis is enabled. * @@ -29,7 +31,7 @@ public function is_enabled(): bool { * @return string */ public function get_name(): string { - return 'previouslyUsedKeyphrase'; + return self::NAME; } /** diff --git a/src/editors/framework/readability-analysis.php b/src/editors/framework/readability-analysis.php index 03accffe776..9e3b4b9cf9a 100644 --- a/src/editors/framework/readability-analysis.php +++ b/src/editors/framework/readability-analysis.php @@ -9,6 +9,7 @@ * This class describes the Readability analysis feature. */ class Readability_Analysis implements Analysis_Feature_Interface { + public const NAME = 'readabilityAnalysis'; /** * The options helper. @@ -59,7 +60,7 @@ private function is_globally_enabled(): bool { * @return string The name. */ public function get_name(): string { - return 'readabilityAnalysis'; + return self::NAME; } /** diff --git a/src/editors/framework/word-form-recognition.php b/src/editors/framework/word-form-recognition.php index da63caf798e..e54dbf828ee 100644 --- a/src/editors/framework/word-form-recognition.php +++ b/src/editors/framework/word-form-recognition.php @@ -10,6 +10,8 @@ */ class Word_Form_Recognition implements Analysis_Feature_Interface { + public const NAME = 'wordFormRecognition'; + /** * The language helper. * @@ -41,7 +43,7 @@ public function is_enabled(): bool { * @return string */ public function get_name(): string { - return 'wordFormRecognition'; + return self::NAME; } /** diff --git a/src/general/user-interface/general-page-integration.php b/src/general/user-interface/general-page-integration.php index 5c7f76f5edf..0a119835477 100644 --- a/src/general/user-interface/general-page-integration.php +++ b/src/general/user-interface/general-page-integration.php @@ -6,6 +6,7 @@ use Yoast\WP\SEO\Actions\Alert_Dismissal_Action; use Yoast\WP\SEO\Conditionals\Admin\Non_Network_Admin_Conditional; use Yoast\WP\SEO\Conditionals\Admin_Conditional; +use Yoast\WP\SEO\Dashboard\Application\Configuration\Dashboard_Configuration; use Yoast\WP\SEO\Helpers\Current_Page_Helper; use Yoast\WP\SEO\Helpers\Notification_Helper; use Yoast\WP\SEO\Helpers\Product_Helper; @@ -30,6 +31,13 @@ class General_Page_Integration implements Integration_Interface { */ protected $notification_helper; + /** + * The dashboard configuration. + * + * @var Dashboard_Configuration + */ + private $dashboard_configuration; + /** * Holds the WPSEO_Admin_Asset_Manager. * @@ -75,13 +83,14 @@ class General_Page_Integration implements Integration_Interface { /** * Constructs Academy_Integration. * - * @param WPSEO_Admin_Asset_Manager $asset_manager The WPSEO_Admin_Asset_Manager. - * @param Current_Page_Helper $current_page_helper The Current_Page_Helper. - * @param Product_Helper $product_helper The Product_Helper. - * @param Short_Link_Helper $shortlink_helper The Short_Link_Helper. - * @param Notification_Helper $notification_helper The Notification_Helper. - * @param Alert_Dismissal_Action $alert_dismissal_action The alert dismissal action. - * @param Promotion_Manager $promotion_manager The promotion manager. + * @param WPSEO_Admin_Asset_Manager $asset_manager The WPSEO_Admin_Asset_Manager. + * @param Current_Page_Helper $current_page_helper The Current_Page_Helper. + * @param Product_Helper $product_helper The Product_Helper. + * @param Short_Link_Helper $shortlink_helper The Short_Link_Helper. + * @param Notification_Helper $notification_helper The Notification_Helper. + * @param Alert_Dismissal_Action $alert_dismissal_action The alert dismissal action. + * @param Promotion_Manager $promotion_manager The promotion manager. + * @param Dashboard_Configuration $dashboard_configuration The dashboard configuration. */ public function __construct( WPSEO_Admin_Asset_Manager $asset_manager, @@ -90,15 +99,17 @@ public function __construct( Short_Link_Helper $shortlink_helper, Notification_Helper $notification_helper, Alert_Dismissal_Action $alert_dismissal_action, - Promotion_Manager $promotion_manager + Promotion_Manager $promotion_manager, + Dashboard_Configuration $dashboard_configuration ) { - $this->asset_manager = $asset_manager; - $this->current_page_helper = $current_page_helper; - $this->product_helper = $product_helper; - $this->shortlink_helper = $shortlink_helper; - $this->notification_helper = $notification_helper; - $this->alert_dismissal_action = $alert_dismissal_action; - $this->promotion_manager = $promotion_manager; + $this->asset_manager = $asset_manager; + $this->current_page_helper = $current_page_helper; + $this->product_helper = $product_helper; + $this->shortlink_helper = $shortlink_helper; + $this->notification_helper = $notification_helper; + $this->alert_dismissal_action = $alert_dismissal_action; + $this->promotion_manager = $promotion_manager; + $this->dashboard_configuration = $dashboard_configuration; } /** @@ -202,6 +213,7 @@ private function get_script_data() { 'alerts' => $this->notification_helper->get_alerts(), 'currentPromotions' => $this->promotion_manager->get_current_promotions(), 'dismissedAlerts' => $this->alert_dismissal_action->all_dismissed(), + 'dashboard' => $this->dashboard_configuration->get_configuration(), ]; } } diff --git a/src/helpers/user-helper.php b/src/helpers/user-helper.php index d319a8c2ad9..a34dbf0d529 100644 --- a/src/helpers/user-helper.php +++ b/src/helpers/user-helper.php @@ -65,6 +65,20 @@ public function get_current_user_id() { return \get_current_user_id(); } + /** + * Returns the current users display_name. + * + * @return string + */ + public function get_current_user_display_name(): string { + $user = \wp_get_current_user(); + if ( $user && $user->display_name ) { + return $user->display_name; + } + + return ''; + } + /** * Updates user meta field for a user. * diff --git a/tests/Unit/Editors/Domain/Analysis_Features/Analysis_Features_List_Test.php b/tests/Unit/Editors/Domain/Analysis_Features/Analysis_Features_List_Test.php index 3792992aba0..435076d252e 100644 --- a/tests/Unit/Editors/Domain/Analysis_Features/Analysis_Features_List_Test.php +++ b/tests/Unit/Editors/Domain/Analysis_Features/Analysis_Features_List_Test.php @@ -22,16 +22,6 @@ final class Analysis_Features_List_Test extends TestCase { */ private $instance; - /** - * Set up the test. - * - * @return void - */ - protected function set_up(): void { - parent::set_up(); - $this->instance = new Analysis_Features_List(); - } - /** * Tests the getters. * @@ -51,4 +41,34 @@ public function test_parse_to_legacy_array(): void { $this->instance->parse_to_legacy_array() ); } + + /** + * Tests the to array. + * + * @covers ::add_feature + * @covers ::parse_to_array + * + * @return void + */ + public function test_parse_to_array(): void { + $this->instance->add_feature( new Analysis_Feature( false, 'name-false', 'legacy-key-false' ) ); + $this->instance->add_feature( new Analysis_Feature( true, 'name-true', 'legacy-key-true' ) ); + $this->assertSame( + [ + 'name-false' => false, + 'name-true' => true, + ], + $this->instance->to_array() + ); + } + + /** + * Set up the test. + * + * @return void + */ + protected function set_up(): void { + parent::set_up(); + $this->instance = new Analysis_Features_List(); + } } diff --git a/tests/Unit/General/User_Interface/General_Page_Integration_Test.php b/tests/Unit/General/User_Interface/General_Page_Integration_Test.php index bfe124bd1e9..e40003f3148 100644 --- a/tests/Unit/General/User_Interface/General_Page_Integration_Test.php +++ b/tests/Unit/General/User_Interface/General_Page_Integration_Test.php @@ -8,6 +8,7 @@ use Yoast\WP\SEO\Actions\Alert_Dismissal_Action; use Yoast\WP\SEO\Conditionals\Admin\Non_Network_Admin_Conditional; use Yoast\WP\SEO\Conditionals\Admin_Conditional; +use Yoast\WP\SEO\Dashboard\Application\Configuration\Dashboard_Configuration; use Yoast\WP\SEO\General\User_Interface\General_Page_Integration; use Yoast\WP\SEO\Helpers\Current_Page_Helper; use Yoast\WP\SEO\Helpers\Notification_Helper; @@ -35,45 +36,52 @@ final class General_Page_Integration_Test extends TestCase { /** * Holds the Current_Page_Helper. * - * @var Current_Page_Helper + * @var Mockery\MockInterface|Current_Page_Helper */ private $current_page_helper; /** * Holds the Product_Helper. * - * @var Product_Helper + * @var Mockery\MockInterface|Product_Helper */ private $product_helper; /** * Holds the Short_Link_Helper. * - * @var Short_Link_Helper + * @var Mockery\MockInterface|Short_Link_Helper */ private $shortlink_helper; /** * Holds the Notification_Helper. * - * @var Notification_Helper + * @var Mockery\MockInterface|Notification_Helper */ private $notifications_helper; /** * Holds the alert dismissal action. * - * @var Alert_Dismissal_Action + * @var Mockery\MockInterface|Alert_Dismissal_Action */ private $alert_dismissal_action; /** * Holds the promotion manager. * - * @var Promotion_Manager + * @var Mockery\MockInterface|Promotion_Manager */ private $promotion_manager; + /** + * Holds the dashboard configuration. + * + * @var Mockery\MockInterface|Dashboard_Configuration + */ + private $dashboard_configuration; + /** * The class under test. * @@ -89,13 +97,14 @@ final class General_Page_Integration_Test extends TestCase { public function set_up() { $this->stubTranslationFunctions(); - $this->asset_manager = Mockery::mock( WPSEO_Admin_Asset_Manager::class ); - $this->current_page_helper = Mockery::mock( Current_Page_Helper::class ); - $this->product_helper = Mockery::mock( Product_Helper::class ); - $this->shortlink_helper = Mockery::mock( Short_Link_Helper::class ); - $this->notifications_helper = Mockery::mock( Notification_Helper::class ); - $this->alert_dismissal_action = Mockery::mock( Alert_Dismissal_Action::class ); - $this->promotion_manager = Mockery::mock( Promotion_Manager::class ); + $this->asset_manager = Mockery::mock( WPSEO_Admin_Asset_Manager::class ); + $this->current_page_helper = Mockery::mock( Current_Page_Helper::class ); + $this->product_helper = Mockery::mock( Product_Helper::class ); + $this->shortlink_helper = Mockery::mock( Short_Link_Helper::class ); + $this->notifications_helper = Mockery::mock( Notification_Helper::class ); + $this->alert_dismissal_action = Mockery::mock( Alert_Dismissal_Action::class ); + $this->promotion_manager = Mockery::mock( Promotion_Manager::class ); + $this->dashboard_configuration = Mockery::mock( Dashboard_Configuration::class ); $this->instance = new General_Page_Integration( $this->asset_manager, @@ -104,7 +113,8 @@ public function set_up() { $this->shortlink_helper, $this->notifications_helper, $this->alert_dismissal_action, - $this->promotion_manager + $this->promotion_manager, + $this->dashboard_configuration ); } @@ -125,7 +135,8 @@ public function test_construct() { $this->shortlink_helper, $this->notifications_helper, $this->alert_dismissal_action, - $this->promotion_manager + $this->promotion_manager, + $this->dashboard_configuration ) ); } @@ -331,6 +342,11 @@ public function expect_get_script_data() { ->once() ->andReturn( [] ); + $this->dashboard_configuration + ->expects( 'get_configuration' ) + ->once() + ->andReturn( [] ); + return $link_params; } } diff --git a/tests/Unit/Helpers/User_Helper_Test.php b/tests/Unit/Helpers/User_Helper_Test.php index f461ad81c52..48632ec35b5 100644 --- a/tests/Unit/Helpers/User_Helper_Test.php +++ b/tests/Unit/Helpers/User_Helper_Test.php @@ -3,6 +3,7 @@ namespace Yoast\WP\SEO\Tests\Unit\Helpers; use Brain\Monkey\Functions; +use WP_User; use Yoast\WP\SEO\Helpers\User_Helper; use Yoast\WP\SEO\Tests\Unit\TestCase; @@ -139,4 +140,42 @@ public function test_delete_meta() { $this->assertTrue( $this->instance->delete_meta( 1, 'key', 'value' ) ); } + + /** + * Tests that get_current_user_display_name return the display_name if its there or an empty string. + * + * @covers ::get_current_user_display_name + * + * @dataProvider current_user_display_name_provider + * + * @param WP_User|null $user The user. + * @param string $expected_display_name The expected display name. + * + * @return void + */ + public function test_get_current_user_display_name( $user, $expected_display_name ) { + Functions\expect( 'wp_get_current_user' ) + ->once() + ->andReturn( $user ); + + $this->assertSame( $expected_display_name, $this->instance->get_current_user_display_name() ); + } + + /** + * Data provider for current_user_display_name_provider test. + * + * @return array> + */ + public static function current_user_display_name_provider() { + $user1 = new WP_User(); + $user1->display_name = 'admin'; + $user2 = new WP_User(); + $user2->display_name = 'First_name'; + + return [ + [ $user1, 'admin' ], + [ $user2, 'First_name' ], + [ null, '' ], + ]; + } } diff --git a/tests/WP/Admin/Meta_Columns_Test.php b/tests/WP/Admin/Meta_Columns_Test.php index 24e25460c3e..24e44e3f095 100644 --- a/tests/WP/Admin/Meta_Columns_Test.php +++ b/tests/WP/Admin/Meta_Columns_Test.php @@ -85,11 +85,6 @@ public static function determine_seo_filters_dataprovider() { [ 'na', [ - [ - 'key' => '_yoast_wpseo_meta-robots-noindex', - 'value' => 'needs-a-value-anyway', - 'compare' => 'NOT EXISTS', - ], [ 'key' => WPSEO_Meta::$meta_prefix . 'linkdex', 'value' => 'needs-a-value-anyway', @@ -131,12 +126,24 @@ public static function determine_readability_filters_dataprovider() { [ 'bad', [ + 'relation' => 'OR', [ 'key' => WPSEO_Meta::$meta_prefix . 'content_score', 'value' => [ 1, 40 ], 'type' => 'numeric', 'compare' => 'BETWEEN', ], + [ + [ + 'key' => WPSEO_Meta::$meta_prefix . 'content_score', + 'value' => 'needs-a-value-anyway', + 'compare' => 'NOT EXISTS', + ], + [ + 'key' => WPSEO_Meta::$meta_prefix . 'estimated-reading-time-minutes', + 'compare' => 'EXISTS', + ], + ], ], ], [ @@ -161,6 +168,30 @@ public static function determine_readability_filters_dataprovider() { ], ], ], + [ + 'na', + [ + [ + 'key' => WPSEO_Meta::$meta_prefix . 'estimated-reading-time-minutes', + 'value' => 'needs-a-value-anyway', + 'compare' => 'NOT EXISTS', + ], + [ + 'relation' => 'OR', + [ + 'key' => WPSEO_Meta::$meta_prefix . 'content_score', + 'value' => 1, + 'type' => 'numeric', + 'compare' => '<', + ], + [ + 'key' => WPSEO_Meta::$meta_prefix . 'content_score', + 'value' => 'needs-a-value-anyway', + 'compare' => 'NOT EXISTS', + ], + ], + ], + ], ]; } diff --git a/tests/WP/Inc/Rank_Test.php b/tests/WP/Inc/Rank_Test.php index 21d92c47a9f..c0b4e23d66a 100644 --- a/tests/WP/Inc/Rank_Test.php +++ b/tests/WP/Inc/Rank_Test.php @@ -189,6 +189,7 @@ public static function provider_get_drop_down_readability_labels() { [ WPSEO_Rank::BAD, 'Readability: Needs improvement' ], [ WPSEO_Rank::OK, 'Readability: OK' ], [ WPSEO_Rank::GOOD, 'Readability: Good' ], + [ WPSEO_Rank::NO_FOCUS, 'Readability: Not analyzed' ], ]; }