Changed code injection to full screen editor. (#19259)

fixes PROD-7
https://ghost.slack.com/archives/C0568LN2CGJ/p1701690959138659

- Changed the code injector editor to only use the full screen editor in
its own modal.

---------

Co-authored-by: Peter Zimon <peter.zimon@gmail.com>
This commit is contained in:
Ronald Langeveld 2023-12-08 09:11:52 +02:00 committed by GitHub
parent 8cbf133614
commit 181c5c0920
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 143 additions and 142 deletions

View File

@ -5,6 +5,8 @@ export type Tab<ID = string> = {
id: ID;
title: string;
counter?: number | null;
tabWrapperClassName?: string;
containerClassName?: string;
/**
* Optional, so you can just use the tabs to other views
@ -102,6 +104,7 @@ export interface TabViewProps<ID = string> {
border?: boolean;
buttonBorder?: boolean;
width?: TabWidth;
containerClassName?: string;
}
function TabView<ID extends string = string>({
@ -110,7 +113,8 @@ function TabView<ID extends string = string>({
selectedTab,
border = true,
buttonBorder = border,
width = 'normal'
width = 'normal',
containerClassName
}: TabViewProps<ID>) {
if (tabs.length !== 0 && selectedTab === undefined) {
selectedTab = tabs[0].id;
@ -126,7 +130,7 @@ function TabView<ID extends string = string>({
};
return (
<section>
<section className={containerClassName}>
<TabList
border={border}
buttonBorder={buttonBorder}
@ -139,8 +143,8 @@ function TabView<ID extends string = string>({
return (
<>
{tab.contents &&
<div key={tab.id} className={`${selectedTab === tab.id ? 'block' : 'hidden'}`} role='tabpanel'>
<div>{tab.contents}</div>
<div key={tab.id} className={`${selectedTab === tab.id ? 'block' : 'hidden'} ${tab.tabWrapperClassName}`} role='tabpanel'>
<div className={tab.containerClassName}>{tab.contents}</div>
</div>
}
</>

View File

@ -1,93 +1,26 @@
import CodeModal from './code/CodeModal';
import NiceModal from '@ebay/nice-modal-react';
import React, {useMemo, useRef, useState} from 'react';
import React from 'react';
import TopLevelGroup from '../../TopLevelGroup';
import useSettingGroup from '../../../hooks/useSettingGroup';
import {Button, CodeEditor, TabView, withErrorBoundary} from '@tryghost/admin-x-design-system';
import {ReactCodeMirrorRef} from '@uiw/react-codemirror';
import {getSettingValues} from '@tryghost/admin-x-framework/api/settings';
import {Button, SettingGroupHeader, withErrorBoundary} from '@tryghost/admin-x-design-system';
const CodeInjection: React.FC<{ keywords: string[] }> = ({keywords}) => {
const {
localSettings,
isEditing,
saveState,
handleSave,
handleCancel,
updateSetting,
handleEditingChange
} = useSettingGroup();
const [headerContent, footerContent] = getSettingValues<string>(localSettings, ['codeinjection_head', 'codeinjection_foot']);
const [selectedTab, setSelectedTab] = useState<'header' | 'footer'>('header');
const headerEditorRef = useRef<ReactCodeMirrorRef>(null);
const footerEditorRef = useRef<ReactCodeMirrorRef>(null);
const html = useMemo(() => import('@codemirror/lang-html').then(module => module.html()), []);
const headerProps = {
extensions: [html],
hint: 'Code here will be injected into the {{ghost_head}} tag on every page of the site',
value: headerContent || '',
onChange: (value: string) => updateSetting('codeinjection_head', value)
};
const footerProps = {
extensions: [html],
hint: 'Code here will be injected into the {{ghost_foot}} tag on every page of the site',
value: footerContent || '',
onChange: (value: string) => updateSetting('codeinjection_foot', value)
};
const tabs = [
{
id: 'header',
title: 'Site header',
contents: (<CodeEditor {...headerProps} ref={headerEditorRef} className='mt-2' data-testid='header-code' autoFocus />)
},
{
id: 'footer',
title: 'Site footer',
contents: (<CodeEditor {...footerProps} ref={footerEditorRef} className='mt-2' data-testid='footer-code' />)
}
] as const;
return (
<TopLevelGroup
customHeader={
<div className='z-10 flex items-start justify-between'>
<SettingGroupHeader description='Add custom code to your publication.' title='Code injection' />
<Button color='green' label='Open' link linkWithPadding onClick={() => {
NiceModal.show(CodeModal);
}} />
</div>
}
description="Add custom code to your publication"
isEditing={isEditing}
keywords={keywords}
navid='code-injection'
saveState={saveState}
testId='code-injection'
title="Code injection"
onCancel={handleCancel}
onEditingChange={handleEditingChange}
onSave={handleSave}
>
{isEditing && (
<div className='relative'>
<TabView<'header' | 'footer'> selectedTab={selectedTab} tabs={tabs} onTabChange={setSelectedTab} />
<Button
className='absolute right-0 top-1 text-sm'
label='Fullscreen'
unstyled
onClick={() => NiceModal.show(CodeModal, {
...(selectedTab === 'header' ? headerProps : footerProps),
afterClose: () => {
if (selectedTab === 'header') {
headerEditorRef.current?.view?.focus();
} else {
footerEditorRef.current?.view?.focus();
}
}
})}
/>
</div>
)}
</TopLevelGroup>
/>
);
};

View File

@ -1,6 +1,10 @@
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import React, {useMemo} from 'react';
import {CodeEditor, Modal} from '@tryghost/admin-x-design-system';
import React, {useMemo, useRef, useState} from 'react';
import useSettingGroup from '../../../../hooks/useSettingGroup';
import {ButtonGroup, CodeEditor, Heading, Modal, TabView} from '@tryghost/admin-x-design-system';
import {ReactCodeMirrorRef} from '@uiw/react-codemirror';
import {getSettingValues} from '@tryghost/admin-x-framework/api/settings';
import {useSaveButton} from '../../../../hooks/useSaveButton';
interface CodeModalProps {
hint?: React.ReactNode;
@ -9,18 +13,91 @@ interface CodeModalProps {
afterClose?: () => void
}
const CodeModal: React.FC<CodeModalProps> = ({hint, value, onChange, afterClose}) => {
const CodeModal: React.FC<CodeModalProps> = ({afterClose}) => {
const {
localSettings,
handleSave,
updateSetting
} = useSettingGroup();
const modal = useModal();
const [headerContent, footerContent] = getSettingValues<string>(localSettings, ['codeinjection_head', 'codeinjection_foot']);
const [selectedTab, setSelectedTab] = useState<'header' | 'footer'>('header');
const headerEditorRef = useRef<ReactCodeMirrorRef>(null);
const footerEditorRef = useRef<ReactCodeMirrorRef>(null);
const html = useMemo(() => import('@codemirror/lang-html').then(module => module.html()), []);
const onOk = () => {
modal.remove();
afterClose?.();
const headerProps = {
extensions: [html],
hint: 'Code here will be injected into the {{ghost_head}} tag on every page of the site',
value: headerContent || '',
onChange: (value: string) => updateSetting('codeinjection_head', value)
};
return <Modal afterClose={afterClose} cancelLabel='' okColor='grey' okLabel='Done' size='full' testId='modal-code' onOk={onOk}>
<CodeEditor extensions={[html]} height='full' hint={hint} value={value} autoFocus onChange={onChange} />
const footerProps = {
extensions: [html],
hint: 'Code here will be injected into the {{ghost_foot}} tag on every page of the site',
value: footerContent || '',
onChange: (value: string) => updateSetting('codeinjection_foot', value)
};
const tabs = [
{
id: 'header',
title: 'Site header',
contents: (<CodeEditor height='full' {...headerProps} ref={headerEditorRef} className='mt-2' data-testid='header-code' autoFocus />),
tabWrapperClassName: 'flex-auto',
containerClassName: 'h-full'
},
{
id: 'footer',
title: 'Site footer',
contents: (<CodeEditor height='full' {...footerProps} ref={footerEditorRef} className='mt-2' data-testid='footer-code' />),
tabWrapperClassName: 'flex-auto',
containerClassName: 'h-full'
}
] as const;
const {savingTitle, isSaving, onSaveClick} = useSaveButton(handleSave, true);
return <Modal
afterClose={afterClose}
cancelLabel='Close'
footer={<></>}
height='full'
size='full'
testId='modal-code-injection'
>
<div className='flex h-full flex-col'>
<div className='mb-4 flex items-center justify-between'>
<Heading level={2}>Code injection</Heading>
<ButtonGroup buttons={[
{
label: 'Close',
color: 'outline',
onClick: () => {
modal.remove();
afterClose?.();
}
},
{
disabled: isSaving,
label: savingTitle,
color: savingTitle === 'Saved' ? 'green' : 'black',
onClick: onSaveClick
}
]} />
</div>
<TabView<'header' | 'footer'>
containerClassName='flex-auto flex flex-col mb-16'
selectedTab={selectedTab}
tabs={tabs}
onTabChange={setSelectedTab}
/>
</div>
</Modal>;
};

View File

@ -0,0 +1,32 @@
import {SaveHandler} from '@tryghost/admin-x-framework/hooks';
import {useState} from 'react';
export const useSaveButton = (handleSave: SaveHandler, fakeWhenUnchanged?: boolean) => {
const [savingTitle, setSavingTitle] = useState('Save');
const [isSaving, setIsSaving] = useState(false);
const onSaveClick = async () => {
setIsSaving(true);
setSavingTitle('Saving');
// Execute the save operation
await handleSave({fakeWhenUnchanged});
// After a second, change the label to 'Saved'
setTimeout(() => {
setSavingTitle('Saved');
// After yet another second, reset to 'Save'
setTimeout(() => {
setSavingTitle('Save');
setIsSaving(false);
}, 1000);
}, 1000);
};
return {
savingTitle,
isSaving,
onSaveClick
};
};

View File

@ -21,23 +21,24 @@ test.describe('Code injection settings', async () => {
const section = page.getByTestId('code-injection');
await section.getByRole('button', {name: 'Edit'}).click();
await section.getByRole('button', {name: 'Open'}).click();
const modal = page.getByTestId('modal-code-injection');
// Click on the CodeMirror content to make sure it's loaded
await section.getByTestId('header-code').locator('.cm-content').click();
await modal.getByTestId('header-code').locator('.cm-content').click();
for (const character of (PADDING + 'testhead').split('')) {
await page.keyboard.press(character);
}
await section.getByRole('tab', {name: 'Site footer'}).click();
await section.getByTestId('footer-code').locator('.cm-content').click();
await modal.getByRole('tab', {name: 'Site footer'}).click();
await modal.getByTestId('footer-code').locator('.cm-content').click();
for (const character of (PADDING + 'testfoot').split('')) {
await page.keyboard.press(character);
}
await section.getByRole('button', {name: 'Save'}).click();
await expect(section.getByRole('button', {name: 'Save'})).toBeHidden();
await modal.getByRole('button', {name: 'Save'}).click();
expect(lastApiRequests.editSettings?.body).toMatchObject({
settings: [
@ -46,50 +47,4 @@ test.describe('Code injection settings', async () => {
]
});
});
test('Supports continuing editing in fullscreen', async ({page}) => {
const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests,
editSettings: {method: 'PUT', path: '/settings/', response: updatedSettingsResponse([
{key: 'codeinjection_head', value: '<1 /><2 /><3 />'}
])}
}});
await page.goto('/');
const section = page.getByTestId('code-injection');
await section.getByRole('button', {name: 'Edit'}).click();
for (const character of PADDING.split('')) {
await page.keyboard.press(character);
}
for (const character of '<1>'.split('')) {
await page.keyboard.press(character);
}
await section.getByRole('button', {name: 'Fullscreen'}).click();
await page.keyboard.press('End');
for (const character of '<2>'.split('')) {
await page.keyboard.press(character);
}
await page.getByTestId('modal-code').getByRole('button', {name: 'Done'}).click();
await page.keyboard.press('End');
for (const character of '<3>'.split('')) {
await page.keyboard.press(character);
}
await section.getByRole('button', {name: 'Save'}).click();
await expect(section.getByRole('button', {name: 'Save'})).toBeHidden();
expect(lastApiRequests.editSettings?.body).toMatchObject({
settings: [
{key: 'codeinjection_head', value: /<1><2><3>$/}
]
});
});
});