Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { readSectionDraft } from '../lib/drafts';
import { recordGuidelinesEvent } from '../lib/tracks';
import { AI_STORE_NAME } from '../store';

export default function SectionGenerateButton( { slug } ) {
export default function SectionGenerateButton( { slug, isShortLabel = false } ) {
const { createErrorNotice } = useDispatch( noticesStore );
const { startSectionLoading, stopSectionLoading, setSuggestion, showUpgradeNotice } =
useDispatch( AI_STORE_NAME );
Expand All @@ -22,8 +22,12 @@ export default function SectionGenerateButton( { slug } ) {
[ slug ]
);
const isEmpty = ! useSectionHasDraft( slug );
const generateLabel = __( 'Generate guidelines', 'jetpack' );
const improveLabel = __( 'Improve guidelines', 'jetpack' );
const generateLabel = isShortLabel

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if this is actually worth doing? I'd keep "Generate guidelines" / "Improve guidelines" on all versions. It's the one AI action in the row, so the longer label is clearer, and it avoids the label changing depending on the Gutenberg version.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is how weird it looks:

Image

? __( 'Generate', 'jetpack' )
: __( 'Generate guidelines', 'jetpack', /* dummy arg to avoid bad minification */ 0 );
const improveLabel = isShortLabel
? __( 'Improve', 'jetpack' )
: __( 'Improve guidelines', 'jetpack', /* dummy arg to avoid bad minification */ 0 );
const label = isEmpty ? generateLabel : improveLabel;

const handleClick = useCallback( async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import { Button } from '@wordpress/components';
import { useDispatch, useSelect } from '@wordpress/data';
import { useCallback, useEffect, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { acceptSectionSuggestion } from '../lib/dom';
import { acceptSectionSuggestion, getTextareaBox } from '../lib/dom';
import { recordGuidelinesEvent } from '../lib/tracks';
import { AI_STORE_NAME } from '../store';
import DiffView from './diff-view';

export default function SuggestionActions( { slug } ) {
export default function SuggestionActions( { slug, isSaveLast = false } ) {
const suggestion = useSelect( select => select( AI_STORE_NAME ).getSuggestion( slug ), [ slug ] );
const sectionLoading = useSelect(
select => select( AI_STORE_NAME ).isSectionLoading( slug ),
Expand All @@ -33,8 +33,10 @@ export default function SuggestionActions( { slug } ) {
const textarea = form.querySelector( 'textarea' );
if ( textarea ) {
setOriginal( textarea.value || '' );
if ( textarea.offsetHeight > 0 ) {
setTextareaHeight( textarea.offsetHeight );
// getBoundingClientRect keeps fractional heights that offsetHeight rounds.
const boxHeight = getTextareaBox( textarea ).getBoundingClientRect().height;
if ( boxHeight > 0 ) {
setTextareaHeight( boxHeight );
Comment thread
aagam-shah marked this conversation as resolved.
} else {
// Fallback when textarea is hidden (e.g. collapsed accordion).
// Compute height from rows attribute to match the textarea.
Expand Down Expand Up @@ -66,6 +68,17 @@ export default function SuggestionActions( { slug } ) {
return null;
}

const acceptButton = (
<Button variant="primary" onClick={ handleAccept }>
{ __( 'Accept suggestion', 'jetpack' ) }
</Button>
);
const dismissButton = (
<Button variant="tertiary" onClick={ handleDismiss }>
{ __( 'Dismiss', 'jetpack' ) }
</Button>
);

return (
<div className="jetpack-content-guidelines-ai__suggestion">
<DiffView
Expand All @@ -74,14 +87,17 @@ export default function SuggestionActions( { slug } ) {
onAccept={ handleAccept }
height={ textareaHeight }
/>
<div className="jetpack-content-guidelines-ai__suggestion-actions">
<Button variant="primary" onClick={ handleAccept }>
{ __( 'Accept suggestion', 'jetpack' ) }
</Button>
<Button variant="tertiary" onClick={ handleDismiss }>
{ __( 'Dismiss', 'jetpack' ) }
</Button>
</div>
{ isSaveLast ? (
<div className="jetpack-content-guidelines-ai__suggestion-actions jetpack-content-guidelines-ai__suggestion-actions--end">
{ dismissButton }
{ acceptButton }
</div>
) : (
<div className="jetpack-content-guidelines-ai__suggestion-actions">
{ acceptButton }
{ dismissButton }
</div>
) }
</div>
);
}
13 changes: 13 additions & 0 deletions projects/plugins/jetpack/_inc/content-guidelines-ai/lib/dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,19 @@ export function setTextareaValue( textarea, value ) {
textarea.dispatchEvent( new Event( 'input', { bubbles: true } ) );
}

/**
* Get the element that draws a textarea's border: the textarea itself, or its
* wrapper in newer Gutenberg (@wordpress/ui).
*
* @param {HTMLTextAreaElement} textarea - The textarea element.
* @return {HTMLElement} The bordered box.
*/
export function getTextareaBox( textarea ) {
return textarea.classList.contains( 'components-textarea-control__input' )
? textarea
: textarea.parentElement;
}

/**
* Accept a section suggestion: write text to the section's textarea and clear
* the suggestion. The input event fired by setTextareaValue() makes
Expand Down
38 changes: 23 additions & 15 deletions projects/plugins/jetpack/_inc/content-guidelines-ai/lib/inject.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import SuggestionBadge from '../components/suggestion-badge';
import UpgradeNotice from '../components/upgrade-notice';
import WelcomeBanner from '../components/welcome-banner';
import { VALID_SECTIONS } from '../constants';
import { getTextareaBox } from './dom';
import { getBlockModalTextarea, startDraftTracking } from './drafts';

// Each injection point tracks both the DOM container and its React root.
Expand Down Expand Up @@ -289,39 +290,46 @@ function runAll() {
{ slug }
);

// Suggestion actions (diff + accept/dismiss) at top of form.
// Newer Gutenberg right-aligns [Clear][Save], older left-aligns [Save][Clear].
// Our section button and Accept/Dismiss follow the same layout.
const saveButton = form.querySelector( 'button[type="submit"]' );
const hStack = saveButton?.parentElement;
const isSaveLast = hStack?.lastElementChild === saveButton;

// Suggestion actions (diff + accept/dismiss) right after the textarea box,
// so the diff takes the textarea's exact place under the field label.
inject(
`actions-${ slug }`,
() => {
const vStack = form.firstElementChild;
return vStack
const textarea = form.querySelector( 'textarea' );
const box = textarea && getTextareaBox( textarea );
return box
? {
parent: vStack,
before: vStack.firstChild,
parent: box.parentElement,
before: box.nextSibling,
className: 'jetpack-content-guidelines-ai__actions-container',
}
: null;
},
SuggestionActions,
{ slug }
{ slug, isSaveLast }
);

// Per-section generate button next to the Save button (the form's
// primary submit button lives in an HStack with the Clear button).
// Per-section generate button in the form's Save/Clear row, on the side
// away from Save so Save stays at the row's outer edge.
inject(
`button-${ slug }`,
() => {
const saveButton = form.querySelector( 'button[type="submit"]' );
const hStack = saveButton?.parentElement;
return hStack
() =>
hStack
? {
parent: hStack,
before: isSaveLast ? hStack.firstElementChild : null,
className: 'jetpack-content-guidelines-ai__section-button-container',
}
: null;
},
: null,
SectionGenerateButton,
{ slug }
// Newer Gutenberg's row has bare "Clear" and "Save", so use short labels.
{ slug, isShortLabel: isSaveLast }
);
}

Expand Down
36 changes: 29 additions & 7 deletions projects/plugins/jetpack/_inc/content-guidelines-ai/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,18 @@ $cg-diff-removed: #f8d7da;
box-sizing: border-box;
}

// Match the section textarea, which Gutenberg 23.9+ renders with @wordpress/ui.
// Like it, use 16px text on small screens to avoid mobile zoom.
.guidelines__list-item:has(textarea:not(.components-textarea-control__input)) .jetpack-content-guidelines-ai__diff {
padding: 10px 12px;
font-size: 16px;
line-height: 1.4;
Comment thread
aagam-shah marked this conversation as resolved.

@media (min-width: 600px) {
font-size: 13px;
}
}

.jetpack-content-guidelines-ai__diff-added {
background: $cg-diff-added;
text-decoration: none;
Expand All @@ -88,9 +100,16 @@ $cg-diff-removed: #f8d7da;
gap: 8px;
}

// Green shimmer overlay on the textarea during AI generation.
// We make the textarea wrapper positioned so the ::after overlay works.
.is-jetpack-loading .components-textarea-control {
// Accept/Dismiss follow Gutenberg's action rows: right-aligned, primary last.
.jetpack-content-guidelines-ai__suggestion-actions--end {
justify-content: flex-end;
}

// Green shimmer overlay on the textarea box during AI generation. In newer
// Gutenberg (@wordpress/ui) that box is the textarea's parent wrapper.
// :is() keeps the list valid in browsers without :has().
.is-jetpack-loading .components-textarea-control,
.is-jetpack-loading :is(:has(> textarea:not(.components-textarea-control__input))) {
position: relative;

&::after {
Expand All @@ -106,7 +125,8 @@ $cg-diff-removed: #f8d7da;
}
}

.is-jetpack-loading textarea {
.is-jetpack-loading textarea,
.is-jetpack-loading :is(:has(> textarea:not(.components-textarea-control__input))) {
border-color: var(--jp-green-40, $cg-green) !important;
pointer-events: none;
}
Expand All @@ -122,9 +142,11 @@ $cg-diff-removed: #f8d7da;
}
}

// Hide Gutenberg's DataForm and Save/Clear when a suggestion is active.
// Also hide the per-section generate button since Accept/Dismiss replaces it.
.has-jetpack-suggestion:not(.block-guideline-modal) > :first-child > :not(.jetpack-content-guidelines-ai__actions-container) {
// While a suggestion is active, the diff replaces the section textarea box
// (the element right before our container) and Accept/Dismiss replace the
// Save/Clear row.
.has-jetpack-suggestion:not(.block-guideline-modal) > :first-child > :not(:first-child),
.has-jetpack-suggestion :is(:has(+ .jetpack-content-guidelines-ai__actions-container)) {
display: none;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { acceptBlockSuggestion, acceptSectionSuggestion, setTextareaValue } from '../lib/dom';
import {
acceptBlockSuggestion,
acceptSectionSuggestion,
getTextareaBox,
setTextareaValue,
} from '../lib/dom';
import { getBlockModalTextarea, getSectionTextarea } from '../lib/drafts';
import { renderBlockModal, renderSections } from './fixtures';

Expand Down Expand Up @@ -112,4 +117,21 @@ describe( 'dom', () => {
expect( clearSuggestion ).toHaveBeenCalledWith( 'core/image' );
} );
} );
describe( 'getTextareaBox', () => {
it( 'returns the textarea for the older TextareaControl, which draws its own border', () => {
document.body.innerHTML =
'<div><textarea class="components-textarea-control__input"></textarea></div>';
const textarea = document.querySelector( 'textarea' );

expect( getTextareaBox( textarea ) ).toBe( textarea );
} );

it( 'returns the wrapper for the @wordpress/ui textarea', () => {
document.body.innerHTML = '<div id="box"><textarea></textarea></div>';

expect( getTextareaBox( document.querySelector( 'textarea' ) ) ).toBe(
document.getElementById( 'box' )
);
} );
} );
} );
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,15 @@ describe( 'SectionGenerateButton', () => {
expect( screen.getByRole( 'button', { name: 'Improve guidelines' } ) ).toBeInTheDocument();
} );

it.each( [
[ false, 'Generate' ],
[ true, 'Improve' ],
] )( 'uses the short label when asked (hasDraft: %s)', ( hasDraft, name ) => {
setup( { hasDraft } );
render( <SectionGenerateButton slug="copy" isShortLabel /> );
expect( screen.getByRole( 'button', { name } ) ).toBeInTheDocument();
} );

it( 'opens the upgrade notice instead of generating without an AI plan', async () => {
setup( { hasFeature: false } );
render( <SectionGenerateButton slug="copy" /> );
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ jest.mock( '@wordpress/components', () => ( {
),
Spinner: () => <span className="components-spinner" />,
} ) );
jest.mock( '../lib/dom', () => ( { acceptSectionSuggestion: jest.fn() } ) );
jest.mock( '../lib/dom', () => ( {
...jest.requireActual( '../lib/dom' ),
acceptSectionSuggestion: jest.fn(),
} ) );
jest.mock( '../lib/tracks', () => ( { recordGuidelinesEvent: jest.fn() } ) );

const clearSuggestion = jest.fn();
Expand Down Expand Up @@ -107,6 +110,20 @@ describe( 'SuggestionActions', () => {
expect( acceptSectionSuggestion ).not.toHaveBeenCalled();
} );

it.each( [
[ 'right-aligned with Accept last', true, [ 'Dismiss', 'Accept suggestion' ] ],
[ 'left-aligned with Accept first', false, [ 'Accept suggestion', 'Dismiss' ] ],
] )( 'orders Accept/Dismiss %s to match the Save row', ( _, isSaveLast, expected ) => {
setup( { suggestion: 'newguideline' } );

render( <SuggestionActions slug="copy" isSaveLast={ isSaveLast } /> );

const names = screen
.getAllByRole( 'button', { name: /^(Accept suggestion|Dismiss)$/ } )
.map( button => button.textContent );
expect( names ).toEqual( expected );
} );

it( 'captures the current section draft as the diff baseline and flags the form', async () => {
// Single-word draft/suggestion so the diff is one clean removed token.
setup( { suggestion: 'newguideline' } );
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: bugfix

Content Guidelines: Fix the AI button alignment and the loading shimmer after recent Gutenberg changes.
Loading