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:
parent
8cbf133614
commit
181c5c0920
@ -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>
|
||||
}
|
||||
</>
|
||||
|
@ -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>
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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>;
|
||||
};
|
||||
|
||||
|
32
apps/admin-x-settings/src/hooks/useSaveButton.ts
Normal file
32
apps/admin-x-settings/src/hooks/useSaveButton.ts
Normal 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
|
||||
};
|
||||
};
|
@ -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>$/}
|
||||
]
|
||||
});
|
||||
});
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user