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:
parent
4a45c956ab
commit
01dd039b21
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: add
|
||||
|
||||
Suggest product categories using AI
|
|
@ -0,0 +1 @@
|
|||
@import 'suggestion-pills/suggestion-pills.scss';
|
|
@ -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';
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
export * from './magic-button';
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
export * from './suggestion-pills';
|
|
@ -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>
|
||||
);
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export * from './product-category-suggestions';
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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( ' > ' );
|
||||
} );
|
||||
};
|
|
@ -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',
|
||||
() => ( {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
};
|
|
@ -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 );
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
export const decodeHtmlEntities = ( () => {
|
||||
const textArea = document.createElement( 'textarea' );
|
||||
return ( str: string ): string => {
|
||||
textArea.innerHTML = str;
|
||||
return textArea.value;
|
||||
};
|
||||
} )();
|
|
@ -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';
|
||||
|
|
|
@ -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 ) {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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 ) ?? '';
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue