Suggest product categories with AI (#39437)

* Accept arguments for the TinyContent getContent method.

Used to fetch the plain text version of the description.

* Add a class to the loading message content

* Set the max description length as global constant

* Create a MagicButton component and use it

* Get plain text description instead of HTML

* Return full category hierarchy (Parent > Child)

* Add method to return all available categories on the product edit page

* Helper function to generate product data instructions for the prompt

* Helper function to select category checkboxes on DOM

* Create component to display a list of suggestion items as pills

* Add product category suggestions to product edit page

* Use the AI package to get text completion

* Add tracks

* Add changelog

* Fix merge conflict

* Remove NoMatch state for category suggestions

* Get available categories using WC REST API

* Suggest new categories

* Run separate prompts for existing and new category generation

* Fix overflow in suggestion pills

* Don't include existing selected categories in prompt

* Add util to encode html entities

* Exclude "Uncategorized" category from product data

* Allow excluding properties from the product data instructions

* Create category from suggestion if it doesn't exist

* Show suggestions as links instead of pills

* Ask for feedback after suggestion selected

* Decode html entities in available categories results

* Don't encode html entities when comparing available categories

* Change feedback box style

* Suggest only one category

* Remove log

* Show feedback box after generating suggestions

Instead of showing it after a suggestion is selected

* Fix typo
This commit is contained in:
Nima Karimi 2023-09-12 17:59:26 +01:00 committed by GitHub
parent 4a45c956ab
commit 01dd039b21
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 1349 additions and 19 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Suggest product categories using AI

View File

@ -0,0 +1 @@
@import 'suggestion-pills/suggestion-pills.scss';

View File

@ -1,3 +1,5 @@
export * from './random-loading-message';
export * from './description-completion-buttons';
export * from './magic-button';
export * from './suggestion-pills';
export * from './info-modal';

View File

@ -0,0 +1 @@
export * from './magic-button';

View File

@ -0,0 +1,31 @@
/**
* Internal dependencies
*/
import MagicIcon from '../../../assets/images/icons/magic.svg';
export type MagicButtonProps = {
title?: string;
disabled?: boolean;
onClick: () => void;
label: string;
};
export const MagicButton = ( {
title,
label,
onClick,
disabled = false,
}: MagicButtonProps ) => {
return (
<button
className="button wp-media-button woo-ai-write-it-for-me-btn"
type="button"
disabled={ disabled }
title={ title }
onClick={ onClick }
>
<img src={ MagicIcon } alt="" />
{ label }
</button>
);
};

View File

@ -131,7 +131,9 @@ export const RandomLoadingMessage: React.FC< RandomLoadingMessageProps > = ( {
<span className="woo-ai-loading-message_spinner">
<Spinner />
</span>
<span>{ currentMessage }</span>
<span className="woo-ai-loading-message_content">
{ currentMessage }
</span>
</>
);
};

View File

@ -0,0 +1 @@
export * from './suggestion-pills';

View File

@ -0,0 +1,25 @@
/**
* External dependencies
*/
import React from 'react';
type SuggestionPillItemProps = {
suggestion: string;
onSuggestionClick: ( suggestion: string ) => void;
};
export const SuggestionPillItem: React.FC< SuggestionPillItemProps > = ( {
suggestion,
onSuggestionClick,
} ) => (
<li className="woo-ai-suggestion-pills__item">
<button
className="button woo-ai-suggestion-pills__select-suggestion"
type="button"
onClick={ () => onSuggestionClick( suggestion ) }
>
<span>+ </span>
<span className="suggestion-content">{ suggestion }</span>
</button>
</li>
);

View File

@ -0,0 +1,21 @@
.woo-ai-suggestion-pills {
display: flex;
align-items: center;
gap: 5px;
flex-wrap: wrap;
&__item {
margin-bottom: 0;
}
&__select-suggestion.button {
border-radius: 28px;
min-width: 0;
word-wrap: break-word;
overflow-wrap: anywhere;
white-space: pre-wrap;
white-space: -moz-pre-wrap;
white-space: -pre-wrap;
white-space: -o-pre-wrap;
}
}

View File

@ -0,0 +1,29 @@
/**
* External dependencies
*/
import React from 'react';
/**
* Internal dependencies
*/
import { SuggestionPillItem } from './suggestion-pill-item';
type SuggestionPillsProps = {
suggestions: string[];
onSuggestionClick: ( suggestion: string ) => void;
};
export const SuggestionPills: React.FC< SuggestionPillsProps > = ( {
suggestions,
onSuggestionClick,
} ) => (
<ul className="woo-ai-suggestion-pills">
{ suggestions.map( ( suggestion, index ) => (
<SuggestionPillItem
key={ index }
suggestion={ suggestion }
onSuggestionClick={ () => onSuggestionClick( suggestion ) }
/>
) ) }
</ul>
);

View File

@ -2,3 +2,4 @@ export const WOO_AI_PLUGIN_FEATURE_NAME = 'woo_ai_plugin';
export const MAX_TITLE_LENGTH = 200;
export const MIN_TITLE_LENGTH_FOR_DESCRIPTION = 15;
export const MIN_DESC_LENGTH_FOR_SHORT_DESC = 100;
export const DESCRIPTION_MAX_LENGTH = 300;

View File

@ -9,6 +9,7 @@ import { QueryClient, QueryClientProvider } from 'react-query';
*/
import { WriteItForMeButtonContainer } from './product-description';
import { ProductNameSuggestions } from './product-name';
import { ProductCategorySuggestions } from './product-category';
import { WriteShortDescriptionButtonContainer } from './product-short-description';
import setPreferencesPersistence from './utils/preferencesPersistence';
@ -37,6 +38,16 @@ const renderComponent = ( Component, rootElement ) => {
}
};
const renderProductCategorySuggestions = () => {
const root = document.createElement( 'div' );
root.id = 'woocommerce-ai-app-product-category-suggestions';
renderComponent( ProductCategorySuggestions, root );
// Insert the category suggestions node in the product category meta box.
document.getElementById( 'taxonomy-product_cat' ).append( root );
};
const descriptionButtonRoot = document.getElementById(
'woocommerce-ai-app-product-gpt-button'
);
@ -51,6 +62,7 @@ const shortDescriptionButtonRoot = document.getElementById(
if ( window.JP_CONNECTION_INITIAL_STATE?.connectionStatus?.isActive ) {
renderComponent( WriteItForMeButtonContainer, descriptionButtonRoot );
renderComponent( ProductNameSuggestions, nameSuggestionsRoot );
renderProductCategorySuggestions();
renderComponent(
WriteShortDescriptionButtonContainer,
shortDescriptionButtonRoot

View File

@ -1,3 +1,5 @@
@import 'product-description/product-description.scss';
@import 'product-name/product-name.scss';
@import "components";
@import "product-description/product-description.scss";
@import "product-name/product-name.scss";
@import 'product-category/product-category.scss';
@import 'product-short-description/product-short-description.scss';

View File

@ -0,0 +1,19 @@
.category-suggestions-feedback {
.notice {
position: relative;
span {
vertical-align: middle;
}
.button-group {
.button {
background: initial;
border: none;
}
.button:hover {
text-decoration: underline;
}
}
}
}

View File

@ -0,0 +1,47 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import { recordCategoryTracks } from './utils';
export const CategorySuggestionFeedback = () => {
const [ hide, setHide ] = useState( false );
const submitFeedback = ( positive: boolean ) => {
setHide( true );
recordCategoryTracks( 'feedback', {
response: positive ? 'positive' : 'negative',
} );
};
return (
<div className="category-suggestions-feedback">
{ ! hide && (
<div className="notice notice-info">
<span>{ __( 'How did we do?', 'woocommerce' ) }</span>
<span className="button-group">
<button
type="button"
className="button button-small"
onClick={ () => submitFeedback( true ) }
>
👍
</button>
<button
type="button"
className="button button-small"
onClick={ () => submitFeedback( false ) }
>
👎
</button>
</span>
</div>
) }
</div>
);
};

View File

@ -0,0 +1 @@
export * from './product-category-suggestions';

View File

@ -0,0 +1,372 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useCallback, useEffect, useState } from '@wordpress/element';
import { UseCompletionError } from '@woocommerce/ai';
/**
* Internal dependencies
*/
import { MagicButton, RandomLoadingMessage } from '../components';
import { getCategories, selectCategory } from '../utils';
import AlertIcon from '../../assets/images/icons/alert.svg';
import { getAvailableCategoryPaths, recordCategoryTracks } from './utils';
import { useNewCategorySuggestions } from './useNewCategorySuggestions';
import { useExistingCategorySuggestions } from './useExistingCategorySuggestions';
import { createCategoriesFromPath } from '../utils/categoryCreator';
import { CategorySuggestionFeedback } from './category-suggestion-feedback';
enum SuggestionsState {
Initial,
Fetching,
Failed,
Complete,
None,
}
export const ProductCategorySuggestions = () => {
const [ existingSuggestionsState, setExistingSuggestionsState ] =
useState< SuggestionsState >( SuggestionsState.Initial );
const [ newSuggestionsState, setNewSuggestionsState ] =
useState< SuggestionsState >( SuggestionsState.Initial );
const [ existingSuggestions, setExistingSuggestions ] = useState<
string[]
>( [] );
const [ newSuggestions, setNewSuggestions ] = useState< string[] >( [] );
const [ showFeedback, setShowFeedback ] = useState( false );
let feedbackTimeout: number | null = null;
useEffect( () => {
recordCategoryTracks( 'view_ui' );
}, [] );
/**
* Show the feedback box after a delay.
*/
const showFeedbackAfterDelay = () => {
if ( feedbackTimeout ) {
clearTimeout( feedbackTimeout );
feedbackTimeout = null;
}
feedbackTimeout = setTimeout( () => {
setShowFeedback( true );
}, 5000 );
};
/**
* Reset the feedback box.
*/
const resetFeedbackBox = () => {
if ( feedbackTimeout ) {
clearTimeout( feedbackTimeout );
feedbackTimeout = null;
}
setShowFeedback( false );
};
/**
* Check if a suggestion is valid.
*
* @param suggestion The suggestion to check.
* @param selectedCategories The currently selected categories.
*/
const isSuggestionValid = (
suggestion: string,
selectedCategories: string[]
) => {
return (
suggestion !== __( 'Uncategorized', 'woocommerce' ) &&
! selectedCategories.includes( suggestion )
);
};
/**
* Callback for when the existing category suggestions have been generated.
*
* @param {string[]} existingCategorySuggestions The existing category suggestions.
*/
const onExistingCategorySuggestionsGenerated = async (
existingCategorySuggestions: string[]
) => {
let filtered: string[] = [];
try {
const availableCategories = await getAvailableCategoryPaths();
// Only show suggestions that are valid, available, and not already in the list of new suggestions.
filtered = existingCategorySuggestions.filter(
( suggestion ) =>
isSuggestionValid( suggestion, getCategories() ) &&
availableCategories.includes( suggestion ) &&
! newSuggestions.includes( suggestion )
);
} catch ( e ) {
// eslint-disable-next-line no-console
console.error( 'Unable to fetch available categories.', e );
}
if ( filtered.length === 0 ) {
setExistingSuggestionsState( SuggestionsState.None );
} else {
setExistingSuggestionsState( SuggestionsState.Complete );
}
setExistingSuggestions( filtered );
showFeedbackAfterDelay();
recordCategoryTracks( 'stop', {
reason: 'finished',
suggestions_type: 'existing',
suggestions: existingCategorySuggestions,
valid_suggestions: filtered,
} );
};
/**
* Callback for when the new category suggestions have been generated.
*
* @param {string[]} newCategorySuggestions
*/
const onNewCategorySuggestionsGenerated = async (
newCategorySuggestions: string[]
) => {
let filtered: string[] = [];
try {
const availableCategories = await getAvailableCategoryPaths();
// Only show suggestions that are valid, NOT already available, and not already in the list of existing suggestions.
filtered = newCategorySuggestions.filter(
( suggestion ) =>
isSuggestionValid( suggestion, getCategories() ) &&
! availableCategories.includes( suggestion ) &&
! existingSuggestions.includes( suggestion )
);
} catch ( e ) {
// eslint-disable-next-line no-console
console.error( 'Unable to fetch available categories.', e );
}
if ( filtered.length === 0 ) {
setNewSuggestionsState( SuggestionsState.None );
} else {
setNewSuggestionsState( SuggestionsState.Complete );
}
setNewSuggestions( filtered );
showFeedbackAfterDelay();
recordCategoryTracks( 'stop', {
reason: 'finished',
suggestions_type: 'new',
suggestions: newCategorySuggestions,
valid_suggestions: filtered,
} );
};
/**
* Callback for when an error occurs while generating the existing category suggestions.
*
* @param {UseCompletionError} error
*/
const onExistingCatSuggestionError = ( error: UseCompletionError ) => {
// eslint-disable-next-line no-console
console.debug( 'Streaming error encountered', error );
recordCategoryTracks( 'stop', {
reason: 'error',
suggestions_type: 'existing',
error: error.code ?? error.message,
} );
setExistingSuggestionsState( SuggestionsState.Failed );
};
/**
* Callback for when an error occurs while generating the new category suggestions.
*
* @param {UseCompletionError} error
*/
const onNewCatSuggestionError = ( error: UseCompletionError ) => {
// eslint-disable-next-line no-console
console.debug( 'Streaming error encountered', error );
recordCategoryTracks( 'stop', {
reason: 'error',
suggestions_type: 'new',
error: error.code ?? error.message,
} );
setNewSuggestionsState( SuggestionsState.Failed );
};
const { fetchSuggestions: fetchExistingCategorySuggestions } =
useExistingCategorySuggestions(
onExistingCategorySuggestionsGenerated,
onExistingCatSuggestionError
);
const { fetchSuggestions: fetchNewCategorySuggestions } =
useNewCategorySuggestions(
onNewCategorySuggestionsGenerated,
onNewCatSuggestionError
);
/**
* Callback for when an existing category suggestion is clicked.
*
* @param {string} suggestion The suggestion that was clicked.
*/
const handleExistingSuggestionClick = useCallback(
( suggestion: string ) => {
// remove the selected item from the list of suggestions
setExistingSuggestions(
existingSuggestions.filter( ( s ) => s !== suggestion )
);
selectCategory( suggestion );
recordCategoryTracks( 'select', {
selected_category: suggestion,
suggestions_type: 'existing',
} );
},
[ existingSuggestions ]
);
/**
* Callback for when a new category suggestion is clicked.
*
* @param {string} suggestion The suggestion that was clicked.
*/
const handleNewSuggestionClick = useCallback(
async ( suggestion: string ) => {
// remove the selected item from the list of suggestions
setNewSuggestions(
newSuggestions.filter( ( s ) => s !== suggestion )
);
try {
await createCategoriesFromPath( suggestion );
recordCategoryTracks( 'select', {
selected_category: suggestion,
suggestions_type: 'new',
} );
} catch ( e ) {
// eslint-disable-next-line no-console
console.error( 'Unable to create category', e );
}
},
[ newSuggestions ]
);
const fetchProductSuggestions = async () => {
resetFeedbackBox();
setExistingSuggestions( [] );
setNewSuggestions( [] );
setExistingSuggestionsState( SuggestionsState.Fetching );
setNewSuggestionsState( SuggestionsState.Fetching );
recordCategoryTracks( 'start', {
current_categories: getCategories(),
} );
await Promise.all( [
fetchExistingCategorySuggestions(),
fetchNewCategorySuggestions(),
] );
};
return (
<div className="wc-product-category-suggestions">
<MagicButton
onClick={ fetchProductSuggestions }
disabled={
existingSuggestionsState === SuggestionsState.Fetching ||
newSuggestionsState === SuggestionsState.Fetching
}
label={ __( 'Suggest a category using AI', 'woocommerce' ) }
/>
{ ( existingSuggestionsState === SuggestionsState.Fetching ||
newSuggestionsState === SuggestionsState.Fetching ) && (
<div className="wc-product-category-suggestions__loading notice notice-info">
<p className="wc-product-category-suggestions__loading-message">
<RandomLoadingMessage
isLoading={
existingSuggestionsState ===
SuggestionsState.Fetching ||
newSuggestionsState ===
SuggestionsState.Fetching
}
/>
</p>
</div>
) }
{ existingSuggestionsState === SuggestionsState.None &&
newSuggestionsState === SuggestionsState.None && (
<div className="wc-product-category-suggestions__no-match notice notice-warning">
<p>
{ __(
'Unable to generate a matching category for the product. Please try including more information about the product in the title and description.',
'woocommerce'
) }
</p>
</div>
) }
{ existingSuggestionsState === SuggestionsState.Failed &&
newSuggestionsState === SuggestionsState.Failed && (
<div
className={ `wc-product-category-suggestions__error notice notice-error` }
>
<p className="wc-product-category-suggestions__error-message">
<img src={ AlertIcon } alt="" />
{ __(
`We're currently experiencing high demand for our experimental feature. Please check back in shortly!`,
'woocommerce'
) }
</p>
</div>
) }
{ ( existingSuggestionsState === SuggestionsState.Complete ||
newSuggestionsState === SuggestionsState.Complete ) && (
<div>
<ul className="wc-product-category-suggestions__suggestions">
{ existingSuggestions.map( ( suggestion ) => (
<li key={ suggestion }>
<button
title={ __(
'Select category',
'woocommerce'
) }
className="button-link"
onClick={ () =>
handleExistingSuggestionClick(
suggestion
)
}
>
{ suggestion }
</button>
</li>
) ) }
{ newSuggestions.map( ( suggestion ) => (
<li key={ suggestion }>
<button
title={ __(
'Add and select category',
'woocommerce'
) }
className="button-link"
onClick={ () =>
handleNewSuggestionClick( suggestion )
}
>
{ suggestion }
</button>
</li>
) ) }
</ul>
{ showFeedback && (
<div className="wc-product-category-suggestions__feedback">
<CategorySuggestionFeedback />
</div>
) }
</div>
) }
</div>
);
};

View File

@ -0,0 +1,51 @@
@import "category-suggestion-feedback";
.wc-product-category-suggestions {
&__loading-message {
display: flex;
align-items: center;
.woo-ai-loading-message_spinner {
display: flex;
.woocommerce-spinner {
width: 24px;
height: 24px;
min-width: 24px;
max-height: 24px;
}
}
.woo-ai-loading-message_content {
margin-left: 5px;
}
}
.woo-ai-write-it-for-me-btn {
display: flex;
align-items: center;
img {
filter: invert(32%) sepia(36%) saturate(2913%) hue-rotate(161deg) brightness(87%) contrast(91%);
}
}
&__suggestions {
margin-top: 10px;
margin-bottom: 0;
li:last-child {
margin-bottom: 0;
}
}
.woo-ai-write-it-for-me-btn {
margin: 0;
width: 100%;
display: flex;
justify-content: center;
}
}
.woocommerce-embed-page #wpbody-content .wc-product-category-suggestions .notice {
margin-top: 10px;
margin-bottom: 0;
}

View File

@ -0,0 +1,96 @@
/**
* External dependencies
*/
import {
__experimentalUseCompletion as useCompletion,
UseCompletionError,
} from '@woocommerce/ai';
/**
* Internal dependencies
*/
import { WOO_AI_PLUGIN_FEATURE_NAME } from '../constants';
import { generateProductDataInstructions, ProductProps } from '../utils';
import { getAvailableCategoryPaths } from './utils';
type UseExistingCategorySuggestionsHook = {
fetchSuggestions: () => Promise< void >;
};
export const useExistingCategorySuggestions = (
onSuggestionsGenerated: ( suggestions: string[] ) => void,
onError: ( error: UseCompletionError ) => void
): UseExistingCategorySuggestionsHook => {
const { requestCompletion } = useCompletion( {
feature: WOO_AI_PLUGIN_FEATURE_NAME,
onStreamError: ( error: UseCompletionError ) => {
// eslint-disable-next-line no-console
console.debug( 'Streaming error encountered', error );
onError( error );
},
onCompletionFinished: async ( reason, content ) => {
if ( reason === 'error' ) {
throw Error( 'Invalid response' );
}
if ( ! content ) {
throw Error( 'No suggestions were generated' );
}
try {
const parsed = content
.split( ',' )
.map( ( suggestion ) => {
return suggestion.trim();
} )
.filter( Boolean );
onSuggestionsGenerated( parsed );
} catch ( e ) {
throw Error( 'Unable to parse suggestions' );
}
},
} );
const buildPrompt = async () => {
let availableCategories: string[] = [];
try {
availableCategories = await getAvailableCategoryPaths();
} catch ( e ) {
// eslint-disable-next-line no-console
console.error( 'Unable to fetch available categories', e );
}
const productPropsInstructions = generateProductDataInstructions( {
excludeProps: [ ProductProps.Categories ],
} );
const instructions = [
'You are a WooCommerce SEO and marketing expert.',
`Using the product's ${ productPropsInstructions.includedProps.join(
', '
) } suggest only one category that best matches the product.`,
'Categories can have parents and multi-level children structures like Parent Category > Child Category.',
availableCategories
? `You will be given a list of available categories. Find the best matching category from this list. Available categories are: ${ availableCategories.join(
', '
) }`
: '',
"The product's properties are:",
...productPropsInstructions.instructions,
'Return only one product category, children categories must be separated by >.',
'Here is an example of a valid response:',
'Parent Category > Subcategory > Another Subcategory',
'Do not output the example response. Respond only with the suggested categories. Do not say anything else.',
];
return instructions.join( '\n' );
};
const fetchSuggestions = async () => {
await requestCompletion( await buildPrompt() );
};
return {
fetchSuggestions,
};
};

View File

@ -0,0 +1,82 @@
/**
* External dependencies
*/
import {
__experimentalUseCompletion as useCompletion,
UseCompletionError,
} from '@woocommerce/ai';
/**
* Internal dependencies
*/
import { WOO_AI_PLUGIN_FEATURE_NAME } from '../constants';
import { generateProductDataInstructions, ProductProps } from '../utils';
type UseNewCategorySuggestionsHook = {
fetchSuggestions: () => Promise< void >;
};
export const useNewCategorySuggestions = (
onSuggestionsGenerated: ( suggestions: string[] ) => void,
onError: ( error: UseCompletionError ) => void
): UseNewCategorySuggestionsHook => {
const { requestCompletion } = useCompletion( {
feature: WOO_AI_PLUGIN_FEATURE_NAME,
onStreamError: ( error: UseCompletionError ) => {
// eslint-disable-next-line no-console
console.debug( 'Streaming error encountered', error );
onError( error );
},
onCompletionFinished: async ( reason, content ) => {
if ( reason === 'error' ) {
throw Error( 'Unable to parse suggestions' );
}
if ( ! content ) {
throw Error( 'No suggestions were generated' );
}
try {
const parsed = content
.split( ',' )
.map( ( suggestion ) => {
return suggestion.trim();
} )
.filter( Boolean );
onSuggestionsGenerated( parsed );
} catch ( e ) {
throw Error( 'Unable to parse suggestions' );
}
},
} );
const buildPrompt = async () => {
const productPropsInstructions = generateProductDataInstructions( {
excludeProps: [ ProductProps.Categories ],
} );
const instructions = [
'You are a WooCommerce SEO and marketing expert.',
`Using the product's ${ productPropsInstructions.includedProps.join(
', '
) } suggest the best matching category from the Google standard product category taxonomy hierarchy.`,
'The category can optionally have multi-level children structures like Parent Category > Child Category.',
"The product's properties are:",
...productPropsInstructions.instructions,
'Return only one best matching product category, children categories must be separated by >.',
'Here is an example of a valid response:',
'Parent Category > Child Category > Child of Child Category',
'Do not output the example response. Respond only with the one suggested category. Do not say anything else.',
];
return instructions.join( '\n' );
};
const fetchSuggestions = async () => {
await requestCompletion( await buildPrompt() );
};
return {
fetchSuggestions,
};
};

View File

@ -0,0 +1,84 @@
/**
* External dependencies
*/
import apiFetch from '@wordpress/api-fetch';
/**
* Internal dependencies
*/
import { getPostId, recordTracksFactory, decodeHtmlEntities } from '../utils';
type TracksData = Record< string, string | number | null | Array< string > >;
type CategoryProps = {
id: number;
name: string;
parent: number;
};
type CategoriesApiResponse = CategoryProps[];
export const recordCategoryTracks = recordTracksFactory< TracksData >(
'category_completion',
() => ( {
post_id: getPostId(),
} )
);
/**
* Get all available categories in the store.
*
* @return {string[]} Array of categories.
* @throws {Error} If the API request fails.
*/
export const getAvailableCategories =
async (): Promise< CategoriesApiResponse > => {
const results = await apiFetch< CategoriesApiResponse >( {
path: '/wc/v3/products/categories?per_page=100&fields=id,name,parent',
} );
results.forEach( ( category ) => {
category.name = decodeHtmlEntities( category.name );
} );
return results;
};
/**
* Get all available categories in the store as a hierarchical list of strings.
*
* @return {string[]} Array of category names in hierarchical manner where each parent category is separated by a > character. e.g. "Clothing > Shirts > T-Shirts"
* @throws {Error} If the API request fails.
*/
export const getAvailableCategoryPaths = async (): Promise< string[] > => {
const categories: CategoriesApiResponse = await getAvailableCategories();
// Create a map of categories by ID
const categoryNamesById: Record< number, CategoryProps > =
categories.reduce(
( acc, category ) => ( {
...acc,
[ category.id ]: category,
} ),
{}
);
// Get the hierarchy string for each category
return categories.map( ( category ) => {
const hierarchy: string[] = [ category.name ];
let parent = category.parent;
// Traverse up the category hierarchy until the root category is reached
while ( parent !== 0 ) {
const parentCategory = categoryNamesById[ parent ];
if ( parentCategory ) {
hierarchy.push( parentCategory.name );
parent = parentCategory.parent;
} else {
parent = 0;
}
}
// Reverse the hierarchy array so that the parent category is first
return hierarchy.reverse().join( ' > ' );
} );
};

View File

@ -16,6 +16,7 @@ import {
import {
MAX_TITLE_LENGTH,
MIN_TITLE_LENGTH_FOR_DESCRIPTION,
DESCRIPTION_MAX_LENGTH,
WOO_AI_PLUGIN_FEATURE_NAME,
} from '../constants';
import { InfoModal, StopCompletionBtn, WriteItForMeBtn } from '../components';
@ -32,8 +33,6 @@ import { Attribute } from '../utils/types';
import { translateApiErrors as getApiError } from '../utils/apiErrors';
import { buildShortDescriptionPrompt } from '../product-short-description/product-short-description-button-container';
const DESCRIPTION_MAX_LENGTH = 300;
const recordDescriptionTracks = recordTracksFactory(
'description_completion',
() => ( {

View File

@ -0,0 +1,186 @@
/**
* Internal dependencies
*/
import { getAvailableCategories } from '../product-category/utils';
interface HTMLWPListElement extends HTMLElement {
wpList: {
settings: {
addAfter: (
returnedResponse: XMLDocument,
ajaxSettings: object,
wpListSettings: object
) => void;
};
};
}
declare global {
interface Window {
wpAjax: {
parseAjaxResponse: ( response: object ) => {
responses?: {
data?: string;
}[];
};
};
}
}
type NewCategory = {
name: string;
parent_id?: number;
};
/**
* Creates a category in the product category list. This function can only be used where the product category taxonomy list is available (e.g. on the product edit page).
*/
const createCategory = async ( category: NewCategory ) => {
const newCategoryInput = document.getElementById(
'newproduct_cat'
) as HTMLInputElement;
const newCategoryParentSelect = document.getElementById(
'newproduct_cat_parent'
) as HTMLSelectElement;
const newCategoryAddButton = document.getElementById(
'product_cat-add-submit'
) as HTMLButtonElement;
const addCategoryToggle = document.getElementById(
'product_cat-add-toggle'
) as HTMLButtonElement;
const categoryListElement = document.getElementById(
'product_catchecklist'
) as HTMLWPListElement;
if (
! [
newCategoryInput,
newCategoryParentSelect,
newCategoryAddButton,
addCategoryToggle,
categoryListElement,
].every( Boolean )
) {
throw new Error( 'Unable to find the category list elements' );
}
// show and hide the category inputs to make sure they are rendered at least once
addCategoryToggle.click();
addCategoryToggle.click();
// Preserve original addAfter function for restoration after use
const orgCatListAddAfter = categoryListElement.wpList.settings.addAfter;
const categoryCreatedPromise = new Promise< number >( ( resolve ) => {
categoryListElement.wpList.settings.addAfter = ( ...args ) => {
orgCatListAddAfter( ...args );
categoryListElement.wpList.settings.addAfter = orgCatListAddAfter;
const parsedResponse = window.wpAjax.parseAjaxResponse( args[ 0 ] );
if ( ! parsedResponse?.responses?.[ 0 ].data ) {
throw new Error( 'Unable to parse the ajax response' );
}
const parsedHtml = new DOMParser().parseFromString(
parsedResponse.responses[ 0 ].data,
'text/html'
);
const newlyAddedCategoryCheckbox = Array.from(
parsedHtml.querySelectorAll< HTMLInputElement >(
'input[name="tax_input[product_cat][]"]'
)
).find( ( input ) => {
return (
input.parentElement?.textContent?.trim() === category.name
);
} );
if ( ! newlyAddedCategoryCheckbox ) {
throw new Error( 'Unable to find the newly added category' );
}
resolve( Number( newlyAddedCategoryCheckbox.value ) );
};
} );
// Fill category name and select parent category if available
newCategoryInput.value = category.name;
if ( category.parent_id ) {
const parentEl = newCategoryParentSelect.querySelector(
'option[value="' + category.parent_id + '"]'
) as HTMLOptionElement;
if ( ! parentEl ) {
throw new Error( 'Unable to find the parent category in the list' );
}
newCategoryParentSelect.value = category.parent_id.toString();
parentEl.selected = true;
}
// click the add button to create the category
newCategoryAddButton.click();
return categoryCreatedPromise;
};
/**
* Gets the list of categories to create from a given path. The path is a string of categories separated by a > character. e.g. "Clothing > Shirts > T-Shirts"
*
* @param categoryPath
*/
const getCategoriesToCreate = async (
categoryPath: string
): Promise< NewCategory[] > => {
const categories: NewCategory[] = [];
const orderedList = categoryPath.split( ' > ' );
const availableCategories = await getAvailableCategories();
let parentCategoryId = 0;
orderedList.every( ( categoryName, index ) => {
const matchingCategory = availableCategories.find( ( category ) => {
return (
category.name === categoryName &&
category.parent === parentCategoryId
);
} );
if ( matchingCategory ) {
// This is the parent category ID for the next category in the path
parentCategoryId = matchingCategory.id;
} else {
categories.push( {
name: categoryName,
parent_id: parentCategoryId,
} );
for ( let i = index + 1; i < orderedList.length; i++ ) {
categories.push( {
name: orderedList[ i ],
} );
}
return false;
}
return true;
} );
return categories;
};
/**
* Creates categories from a given path. The path is a string of categories separated by a > character. e.g. "Clothing > Shirts > T-Shirts"
*
* @param categoryPath
*/
export const createCategoriesFromPath = async ( categoryPath: string ) => {
const categoriesToCreate = await getCategoriesToCreate( categoryPath );
while ( categoriesToCreate.length ) {
const newCategoryId = await createCategory(
categoriesToCreate.shift() as NewCategory
);
if ( categoriesToCreate.length ) {
// Set the parent ID of the next category in the list to the ID of the newly created category so that it is created as a child of the newly created category
categoriesToCreate[ 0 ].parent_id = newCategoryId;
}
}
};

View File

@ -0,0 +1,72 @@
/**
* Helper function to select a checkbox if it exists within a element
*
* @param element - The DOM element to check for a checkbox
*/
const checkFirstCheckboxInElement = ( element: HTMLElement ) => {
// Select the checkbox input element if it exists
const checkboxElement: HTMLInputElement | null = element.querySelector(
'label > input[type="checkbox"]'
);
// If the checkbox exists, check it and trigger a 'change' event
if ( checkboxElement ) {
checkboxElement.checked = true;
checkboxElement.dispatchEvent( new Event( 'change' ) );
}
};
/**
* Recursive function to select categories and their children based on the provided ordered list
*
* @param orderedCategories - An ordered list of categories to be selected, starting with the top-level category and ending with the lowest-level category.
* @param categoryElements - A list of HTML List elements representing the categories
*/
const selectCategoriesRecursively = (
orderedCategories: string[],
categoryElements: HTMLLIElement[]
) => {
const categoryToSelect = orderedCategories[ 0 ];
// Find the HTML element that matches the category to be selected
const selectedCategoryElement = categoryElements.find(
( element ) =>
element.querySelector( ':scope > label' )?.textContent?.trim() ===
categoryToSelect
);
// If the category to be selected doesn't exist, terminate the function
if ( ! selectedCategoryElement ) {
return;
}
checkFirstCheckboxInElement( selectedCategoryElement );
// Select all the child categories, if they exist
const subsequentCategories: string[] = orderedCategories.slice( 1 );
const childCategoryElements: HTMLLIElement[] = Array.from(
selectedCategoryElement.querySelectorAll( 'ul.children > li' )
);
if ( subsequentCategories.length && childCategoryElements.length ) {
selectCategoriesRecursively(
subsequentCategories,
childCategoryElements
);
}
};
/**
* Main function to select a category and its children from a provided category path
*
* @param categoryPath - The path to the category, each level separated by a > character.
* e.g. "Clothing > Shirts > T-Shirts"
*/
export const selectCategory = ( categoryPath: string ) => {
const categories = categoryPath.split( '>' ).map( ( name ) => name.trim() );
const categoryListElements: HTMLLIElement[] = Array.from(
document.querySelectorAll( '#product_catchecklist > li' )
);
selectCategoriesRecursively( categories, categoryListElements );
};

View File

@ -0,0 +1,7 @@
export const decodeHtmlEntities = ( () => {
const textArea = document.createElement( 'textarea' );
return ( str: string ): string => {
textArea.innerHTML = str;
return textArea.value;
};
} )();

View File

@ -3,3 +3,6 @@ export * from './shuffleArray';
export * from './recordTracksFactory';
export * from './get-post-id';
export * from './tiny-tools';
export * from './productDataInstructionsGenerator';
export * from './categorySelector';
export * from './htmlEntities';

View File

@ -1,21 +1,86 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { Attribute } from './types';
import { getTinyContent } from '.';
export enum ProductProps {
Name = 'name',
Description = 'description',
Categories = 'categories',
Tags = 'tags',
Attributes = 'attributes',
}
/**
* Retrieves a hierarchy string for the specified category element. This includes the category label and all parent categories separated by a > character.
*
* @param {HTMLInputElement} categoryElement - The category element to get the hierarchy string for.
* @return {string} The hierarchy string for the specified category element. e.g. "Clothing > Shirts > T-Shirts"
*/
const getCategoryHierarchy = ( categoryElement: HTMLElement ) => {
let hierarchy = '';
let parentElement = categoryElement.parentElement;
// Traverse up the DOM Tree until a category list item (LI) is found
while ( parentElement ) {
const isListItem = parentElement.tagName.toUpperCase() === 'LI';
const isRootList = parentElement.id === 'product_catchecklist';
if ( isListItem ) {
const categoryLabel =
parentElement.querySelector( 'label' )?.innerText.trim() || '';
if ( categoryLabel ) {
hierarchy = hierarchy
? `${ categoryLabel } > ${ hierarchy }`
: categoryLabel;
} else {
break;
}
}
if ( isRootList ) {
// If the root category list is found, it means we have reached the top of the hierarchy
break;
}
parentElement = parentElement.parentElement;
}
return hierarchy;
};
/**
* Function to get selected categories in hierarchical manner.
*
* @return {string[]} Array of category hierarchy strings for each selected category.
*/
export const getCategories = (): string[] => {
return Array.from(
// Get all the selected category checkboxes
const checkboxes: NodeListOf< HTMLInputElement > =
document.querySelectorAll(
'#taxonomy-product_cat input[name="tax_input[product_cat][]"]'
)
)
.filter(
( item ) =>
window.getComputedStyle( item, ':before' ).content !== 'none'
)
.map( ( item ) => item.nextSibling?.nodeValue?.trim() || '' )
.filter( Boolean );
'#taxonomy-product_cat input[type="checkbox"][name="tax_input[product_cat][]"]'
);
const categoryElements = Array.from( checkboxes );
// Filter out the Uncategorized category and return the remaining selected categories
const selectedCategories = categoryElements.filter( ( element ) => {
const categoryLabel = element.parentElement?.innerText.trim();
return (
element.checked &&
categoryLabel !== __( 'Uncategorized', 'woocommerce' )
);
} );
// Get the hierarchy string for each selected category and filter out any empty strings
return selectedCategories.map( getCategoryHierarchy ).filter( Boolean );
};
const isElementVisible = ( element: HTMLElement ) =>
@ -88,7 +153,7 @@ export const getDescription = (): string => {
const content = document.querySelector(
'#content'
) as HTMLInputElement;
const tinyContent = getTinyContent();
const tinyContent = getTinyContent( 'content', { format: 'text' } );
if ( content && isElementVisible( content ) ) {
return content.value;
} else if ( tinyContent ) {

View File

@ -0,0 +1,114 @@
/**
* Internal dependencies
*/
import { Attribute } from './types';
import {
getAttributes,
getCategories,
getDescription,
getProductName,
getTags,
ProductProps,
} from '.';
import { DESCRIPTION_MAX_LENGTH, MAX_TITLE_LENGTH } from '../constants';
type PropsFilter = {
excludeProps?: ProductProps[];
allowedProps?: ProductProps[];
};
type InstructionSet = {
includedProps: string[];
instructions: string[];
};
/**
* Function to generate prompt instructions for product data.
*
* @param {PropsFilter} propsFilter Object containing the props to be included or excluded from the instructions.
* @param {ProductProps[]} propsFilter.excludeProps Array of props to be excluded from the instructions.
* @param {ProductProps[]} propsFilter.allowedProps Array of props to be included in the instructions.
*
* @return {string[]} Array of prompt instructions.
*/
export const generateProductDataInstructions = ( {
excludeProps,
allowedProps,
}: PropsFilter = {} ): InstructionSet => {
const isPropertyAllowed = ( prop: ProductProps ): boolean => {
if ( allowedProps ) {
return allowedProps.includes( prop );
}
if ( excludeProps ) {
return ! excludeProps.includes( prop );
}
return true;
};
const productName: string = isPropertyAllowed( ProductProps.Name )
? getProductName()
: '';
const productDescription: string = isPropertyAllowed(
ProductProps.Description
)
? getDescription()
: '';
const productCategories: string[] = isPropertyAllowed(
ProductProps.Categories
)
? getCategories()
: [];
const productTags: string[] = isPropertyAllowed( ProductProps.Tags )
? getTags()
: [];
const productAttributes: Attribute[] = isPropertyAllowed(
ProductProps.Attributes
)
? getAttributes()
: [];
const includedProps: string[] = [];
const productPropsInstructions: string[] = [];
if ( productName ) {
productPropsInstructions.push(
`Name: ${ productName.slice( 0, MAX_TITLE_LENGTH ) }.`
);
includedProps.push( 'name' );
}
if ( productDescription ) {
productPropsInstructions.push(
`Description: ${ productDescription.slice(
0,
DESCRIPTION_MAX_LENGTH
) }.`
);
includedProps.push( ProductProps.Description );
}
if ( productCategories.length ) {
productPropsInstructions.push(
`Product categories: ${ productCategories.join( ', ' ) }.`
);
includedProps.push( ProductProps.Categories );
}
if ( productTags.length ) {
productPropsInstructions.push(
`Tagged with: ${ productTags.join( ', ' ) }.`
);
includedProps.push( ProductProps.Tags );
}
if ( productAttributes.length ) {
productAttributes.forEach( ( { name, values } ) => {
productPropsInstructions.push(
`${ name }: ${ values.join( ', ' ) }.`
);
includedProps.push( name );
} );
}
return {
includedProps,
instructions: productPropsInstructions,
};
};

View File

@ -1,5 +1,5 @@
export type TinyContent = {
getContent: () => string;
getContent: ( args?: object ) => string;
setContent: ( str: string ) => void;
id: string;
on: ( event: string, callback: ( event: Event ) => void ) => void;
@ -39,6 +39,6 @@ export const setTinyContent = ( str: string, editorId?: string ) => {
}
};
export const getTinyContent = ( editorId?: string ) => {
return getTinyContentObject( editorId )?.getContent() ?? '';
export const getTinyContent = ( editorId?: string, args?: object ) => {
return getTinyContentObject( editorId )?.getContent( args ) ?? '';
};