Wired up AdminX Labs settings (#17844)

refs https://github.com/TryGhost/Product/issues/3745

---

This pull request adds and refactors several components and hooks for
the labs and advanced settings in the admin app. It introduces feature
flags for alpha and beta features, and allows the user to upload and
download redirects, routes and content files.
This commit is contained in:
Jono M 2023-08-28 16:24:03 +01:00 committed by GitHub
parent 7c934ebf46
commit 05e6588832
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 380 additions and 118 deletions

View File

@ -48,6 +48,8 @@ export interface ConfigResponseType {
const dataType = 'ConfigResponseType';
export const configDataType = dataType;
export const useBrowseConfig = createQuery<ConfigResponseType>({
dataType,
path: '/config/'

View File

@ -0,0 +1,19 @@
import {createMutation} from '../utils/apiRequests';
import {downloadFromEndpoint} from '../utils/helpers';
export const useImportContent = createMutation<unknown, File>({
method: 'POST',
path: () => '/db/',
body: (file) => {
const formData = new FormData();
formData.append('importfile', file);
return formData;
}
});
export const useDeleteAllContent = createMutation<unknown, null>({
method: 'DELETE',
path: () => '/db/'
});
export const downloadAllContent = () => downloadFromEndpoint('/db/');

View File

@ -0,0 +1,14 @@
import {createMutation} from '../utils/apiRequests';
import {downloadFromEndpoint} from '../utils/helpers';
export const useUploadRedirects = createMutation<unknown, File>({
method: 'POST',
path: () => '/redirects/upload/',
body: (file) => {
const formData = new FormData();
formData.append('redirects', file);
return formData;
}
});
export const downloadRedirects = () => downloadFromEndpoint('/redirects/download/');

View File

@ -0,0 +1,14 @@
import {createMutation} from '../utils/apiRequests';
import {downloadFromEndpoint} from '../utils/helpers';
export const useUploadRoutes = createMutation<unknown, File>({
method: 'POST',
path: () => '/settings/routes/yaml/',
body: (file) => {
const formData = new FormData();
formData.append('routes', file);
return formData;
}
});
export const downloadRoutes = () => downloadFromEndpoint('/settings/routes/yaml/');

View File

@ -63,12 +63,12 @@ export function getSettingValues<ValueType = SettingValue>(settings: Setting[] |
return keys.map(key => settings?.find(setting => setting.key === key)?.value) as ValueType[];
}
export function getSettingValue(settings: Setting[] | null | undefined, key: string): SettingValue {
export function getSettingValue<ValueType = SettingValue>(settings: Setting[] | null | undefined, key: string): ValueType | null {
if (!settings) {
return '';
return null;
}
const setting = settings.find(d => d.key === key);
return setting?.value || null;
return setting?.value as ValueType || null;
}
export function checkStripeEnabled(settings: Setting[], config: Config) {

View File

@ -3,6 +3,7 @@ import React from 'react';
import SettingNavItem from '../admin-x-ds/settings/SettingNavItem';
import SettingNavSection from '../admin-x-ds/settings/SettingNavSection';
import TextField from '../admin-x-ds/global/form/TextField';
import useFeatureFlag from '../hooks/useFeatureFlag';
import useRouting from '../hooks/useRouting';
import {getSettingValues} from '../api/settings';
import {useGlobalData} from './providers/GlobalDataProvider';
@ -19,6 +20,8 @@ const Sidebar: React.FC = () => {
updateRoute(e.currentTarget.name);
};
const hasTipsAndDonations = useFeatureFlag('tipsAndDonations');
return (
<div className="hidden md:!visible md:!block md:h-[calc(100vh-5vmin-84px)] md:w-[240px] md:overflow-y-scroll md:pt-[32px]">
<div className='relative mb-10'>
@ -48,7 +51,7 @@ const Sidebar: React.FC = () => {
<SettingNavItem navid='portal' title="Portal" onClick={handleSectionClick} />
<SettingNavItem navid='access' title="Access" onClick={handleSectionClick} />
<SettingNavItem navid='tiers' title="Tiers" onClick={handleSectionClick} />
<SettingNavItem navid='tips-or-donations' title="Tips or donations" onClick={handleSectionClick} />
{hasTipsAndDonations && <SettingNavItem navid='tips-or-donations' title="Tips or donations" onClick={handleSectionClick} />}
<SettingNavItem navid='analytics' title="Analytics" onClick={handleSectionClick} />
</SettingNavSection>

View File

@ -1,116 +1,20 @@
import AlphaFeatures from './labs/AlphaFeatures';
import BetaFeatures from './labs/BetaFeatures';
import Button from '../../../admin-x-ds/global/Button';
import LabsBubbles from '../../../assets/images/labs-bg.svg';
import List from '../../../admin-x-ds/global/List';
import ListItem from '../../../admin-x-ds/global/ListItem';
import MigrationOptions from './labs/MigrationOptions';
import React, {useState} from 'react';
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
import SettingGroupHeader from '../../../admin-x-ds/settings/SettingGroupHeader';
import TabView from '../../../admin-x-ds/global/TabView';
import Toggle from '../../../admin-x-ds/global/form/Toggle';
import TabView, {Tab} from '../../../admin-x-ds/global/TabView';
import {useGlobalData} from '../../providers/GlobalDataProvider';
const LabItem: React.FC<{
title?: React.ReactNode;
detail?: React.ReactNode;
action?: React.ReactNode;
}> = ({
title,
detail,
action
}) => {
return (
<ListItem
action={action}
bgOnHover={false}
detail={detail}
paddingRight={false}
title={title}
/>
);
};
const MigrationOptions: React.FC = () => {
return (
<List titleSeparator={false}>
<LabItem
action={<Button color='grey' label='Open importer' size='sm' />}
detail='Import posts from a JSON or zip file'
title='Import content'
/>
<LabItem
action={<Button color='grey' label='Export' size='sm' />}
detail='Download all of your posts and settings in a single, glorious JSON file'
title='Export your content'
/>
<LabItem
action={<Button color='red' label='Delete' size='sm' />}
detail='Permanently delete all posts and tags from the database, a hard reset'
title='Delete all content'
/>
</List>
);
};
/*
<a className='text-green' href="" rel="noopener noreferrer" target="_blank"></a>
*/
const AlphaFeatures: React.FC = () => {
return (
<List titleSeparator={false}>
<LabItem
action={<Toggle />}
detail={<>Try out <a className='text-green' href="https://ghost.org/changelog/editor-beta/" rel="noopener noreferrer" target="_blank">Ghost{`'`}s brand new editor</a>, and get early access to the latest features and improvements</>}
title='Ghost editor (beta)'
/>
<LabItem
action={<Button color='grey' label='Open' size='sm' />}
detail={<>A <a className='text-green' href="https://ghost.org/help/importing-from-substack/" rel="noopener noreferrer" target="_blank">step-by-step tool</a> to easily import all your content, members and paid subscriptions</>}
title='Substack migrator'
/>
<LabItem
action={<Toggle />}
detail={<>Translate your membership flows into your publication language (<a className='text-green' href="https://github.com/TryGhost/Ghost/tree/main/ghost/i18n/locales" rel="noopener noreferrer" target="_blank">supported languages</a>). Dont see yours? <a className='text-green' href="https://forum.ghost.org/t/help-translate-ghost-beta/37461" rel="noopener noreferrer" target="_blank">Get involved</a></>}
title='Portal translation'
/>
<LabItem
action={
<div className='flex flex-col items-end gap-1'>
<Button color='grey' label='Upload redirects file' size='sm' />
<Button color='green' label='Download current redirects' link />
</div>
}
detail={<>Configure redirects for old or moved content, <br /> more info in the <a className='text-green' href="https://ghost.org/tutorials/implementing-redirects/" rel="noopener noreferrer" target="_blank">docs</a></>}
title='Redirects'
/>
<LabItem
action={
<div className='flex flex-col items-end gap-1'>
<Button color='grey' label='Upload routes file' size='sm' />
<Button color='green' label='Download current routes' link />
</div>
}
detail='Configure dynamic routing by modifying the routes.yaml file'
title='Routes'
/>
</List>
);
};
const BetaFeautres: React.FC = () => {
return (
<List titleSeparator={false}>
<LabItem
action={<Toggle />}
detail='This is dynamic'
title='Example beta feature'
/>
</List>
);
};
type LabsTab = 'labs-migration-options' | 'labs-alpha-features' | 'labs-beta-features';
const Labs: React.FC<{ keywords: string[] }> = ({keywords}) => {
const [selectedTab, setSelectedTab] = useState<'labs-migration-options' | 'labs-alpha-features' | 'labs-beta-features'>('labs-migration-options');
const [selectedTab, setSelectedTab] = useState<LabsTab>('labs-migration-options');
const [isOpen, setIsOpen] = useState(false);
const {config} = useGlobalData();
const tabs = [
{
@ -119,16 +23,16 @@ const Labs: React.FC<{ keywords: string[] }> = ({keywords}) => {
contents: <MigrationOptions />
},
{
id: 'labs-beta-features',
title: 'Beta features',
contents: <BetaFeatures />
},
config.enableDeveloperExperiments && ({
id: 'labs-alpha-features',
title: 'Alpha features',
contents: <AlphaFeatures />
},
{
id: 'labs-beta-features',
title: 'Beta features',
contents: <BetaFeautres />
}
] as const;
})
].filter(Boolean) as Tab<LabsTab>[];
return (
<SettingGroup

View File

@ -0,0 +1,73 @@
import FeatureToggle from './FeatureToggle';
import LabItem from './LabItem';
import List from '../../../../admin-x-ds/global/List';
import React from 'react';
const features = [{
title: 'URL cache',
description: 'Enable URL Caching',
flag: 'urlCache'
},{
title: 'Lexical multiplayer',
description: 'Enables multiplayer editing in the lexical editor.',
flag: 'lexicalMultiplayer'
},{
title: 'Webmentions',
description: 'Allows viewing received mentions on the dashboard.',
flag: 'webmentions'
},{
title: 'Websockets',
description: 'Test out Websockets functionality at <code>/ghost/#/websockets</code>.',
flag: 'websockets'
},{
title: 'Stripe Automatic Tax',
description: 'Use Stripe Automatic Tax at Stripe Checkout. Needs to be enabled in Stripe',
flag: 'stripeAutomaticTax'
},{
title: 'Email customization',
description: 'Adding more control over the newsletter template',
flag: 'emailCustomization'
},{
title: 'Collections',
description: 'Enables Collections 2.0',
flag: 'collections'
},{
title: 'Collections Card',
description: 'Enables the Collections Card for pages - requires Collections and the beta Editor to be enabled',
flag: 'collectionsCard'
},{
title: 'Admin X',
description: 'Enables Admin X, the new admin UI for Ghost',
flag: 'adminXSettings'
},{
title: 'Mail Events',
description: 'Enables processing of mail events',
flag: 'mailEvents'
},{
title: 'Convert to Lexical',
description: 'Convert mobiledoc posts to lexical upon opening in the editor.',
flag: 'convertToLexical'
},{
title: 'Import Member Tier',
description: 'Enables tier to be specified when importing members',
flag: 'importMemberTier'
},{
title: 'Tips & donations',
description: 'Enables publishers to collect one-time payments',
flag: 'tipsAndDonations'
}];
const AlphaFeatures: React.FC = () => {
return (
<List titleSeparator={false}>
{features.map(feature => (
<LabItem
action={<FeatureToggle flag={feature.flag} />}
detail={feature.description}
title={feature.title} />
))}
</List>
);
};
export default AlphaFeatures;

View File

@ -0,0 +1,77 @@
import Button from '../../../../admin-x-ds/global/Button';
import FeatureToggle from './FeatureToggle';
import FileUpload from '../../../../admin-x-ds/global/form/FileUpload';
import LabItem from './LabItem';
import List from '../../../../admin-x-ds/global/List';
import React, {useState} from 'react';
import useRouting from '../../../../hooks/useRouting';
import {downloadRedirects, useUploadRedirects} from '../../../../api/redirects';
import {downloadRoutes, useUploadRoutes} from '../../../../api/routes';
import {showToast} from '../../../../admin-x-ds/global/Toast';
const BetaFeatures: React.FC = () => {
const {updateRoute} = useRouting();
const {mutateAsync: uploadRedirects} = useUploadRedirects();
const {mutateAsync: uploadRoutes} = useUploadRoutes();
const [redirectsUploading, setRedirectsUploading] = useState(false);
const [routesUploading, setRoutesUploading] = useState(false);
return (
<List titleSeparator={false}>
<LabItem
action={<FeatureToggle flag='lexicalEditor' />}
detail={<>Try out <a className='text-green' href="https://ghost.org/changelog/editor-beta/" rel="noopener noreferrer" target="_blank">Ghost{`'`}s brand new editor</a>, and get early access to the latest features and improvements</>}
title='Ghost editor (beta)' />
<LabItem
action={<Button color='grey' label='Open' size='sm' onClick={() => updateRoute({isExternal: true, route: 'migrate'})} />}
detail={<>A <a className='text-green' href="https://ghost.org/help/importing-from-substack/" rel="noopener noreferrer" target="_blank">step-by-step tool</a> to easily import all your content, members and paid subscriptions</>}
title='Substack migrator' />
<LabItem
action={<FeatureToggle flag='i18n' />}
detail={<>Translate your membership flows into your publication language (<a className='text-green' href="https://github.com/TryGhost/Ghost/tree/main/ghost/i18n/locales" rel="noopener noreferrer" target="_blank">supported languages</a>). Dont see yours? <a className='text-green' href="https://forum.ghost.org/t/help-translate-ghost-beta/37461" rel="noopener noreferrer" target="_blank">Get involved</a></>}
title='Portal translation' />
<LabItem
action={<div className='flex flex-col items-end gap-1'>
<FileUpload
id='upload-redirects'
onUpload={async (file) => {
setRedirectsUploading(true);
await uploadRedirects(file);
showToast({
type: 'success',
message: 'Redirects uploaded successfully'
});
setRedirectsUploading(false);
}}
>
<Button color='grey' label={redirectsUploading ? 'Uploading ...' : 'Upload redirects file'} size='sm' tag='div' />
</FileUpload>
<Button color='green' label='Download current redirects' link onClick={() => downloadRedirects()} />
</div>}
detail={<>Configure redirects for old or moved content, <br /> more info in the <a className='text-green' href="https://ghost.org/tutorials/implementing-redirects/" rel="noopener noreferrer" target="_blank">docs</a></>}
title='Redirects' />
<LabItem
action={<div className='flex flex-col items-end gap-1'>
<FileUpload
id='upload-routes'
onUpload={async (file) => {
setRoutesUploading(true);
await uploadRoutes(file);
showToast({
type: 'success',
message: 'Routes uploaded successfully'
});
setRoutesUploading(false);
}}
>
<Button color='grey' label={routesUploading ? 'Uploading ...' : 'Upload routes file'} size='sm' tag='div' />
</FileUpload>
<Button color='green' label='Download current routes' link onClick={() => downloadRoutes()} />
</div>}
detail='Configure dynamic routing by modifying the routes.yaml file'
title='Routes' />
</List>
);
};
export default BetaFeatures;

View File

@ -0,0 +1,32 @@
import React from 'react';
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
import {ConfigResponseType, configDataType} from '../../../../api/config';
import {getSettingValue, useEditSettings} from '../../../../api/settings';
import {useGlobalData} from '../../../providers/GlobalDataProvider';
import {useQueryClient} from '@tanstack/react-query';
const FeatureToggle: React.FC<{ flag: string; }> = ({flag}) => {
const {settings} = useGlobalData();
const labs = JSON.parse(getSettingValue<string>(settings, 'labs') || '{}');
const {mutateAsync: editSettings} = useEditSettings();
const client = useQueryClient();
return <Toggle checked={labs[flag]} onChange={async () => {
const newValue = !labs[flag];
await editSettings([{
key: 'labs',
value: JSON.stringify({...labs, [flag]: newValue})
}]);
client.setQueriesData([configDataType], current => ({
config: {
...(current as ConfigResponseType).config,
labs: {
...(current as ConfigResponseType).config.labs,
[flag]: newValue
}
}
}));
}} />;
};
export default FeatureToggle;

View File

@ -0,0 +1,21 @@
import ListItem from '../../../../admin-x-ds/global/ListItem';
import React from 'react';
const LabItem: React.FC<{
title?: React.ReactNode;
detail?: React.ReactNode;
action?: React.ReactNode;
}> = ({
title, detail, action
}) => {
return (
<ListItem
action={action}
bgOnHover={false}
detail={detail}
paddingRight={false}
title={title} />
);
};
export default LabItem;

View File

@ -0,0 +1,87 @@
import Button from '../../../../admin-x-ds/global/Button';
import ConfirmationModal from '../../../../admin-x-ds/global/modal/ConfirmationModal';
import FileUpload from '../../../../admin-x-ds/global/form/FileUpload';
import LabItem from './LabItem';
import List from '../../../../admin-x-ds/global/List';
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import React, {useState} from 'react';
import {downloadAllContent, useDeleteAllContent, useImportContent} from '../../../../api/db';
import {showToast} from '../../../../admin-x-ds/global/Toast';
import {useQueryClient} from '@tanstack/react-query';
const ImportModalContent = () => {
const modal = useModal();
const {mutateAsync: importContent} = useImportContent();
const [uploading, setUploading] = useState(false);
return <FileUpload
id="import-file"
onUpload={async (file) => {
setUploading(true);
await importContent(file);
modal.remove();
NiceModal.show(ConfirmationModal, {
title: 'Import in progress',
prompt: `Your import is being processed, and you'll receive a confirmation email as soon as it's complete. Usually this only takes a few minutes, but larger imports may take longer.`,
cancelLabel: '',
okLabel: 'Got it',
onOk: confirmModal => confirmModal?.remove(),
formSheet: false
});
}}
>
<div className="cursor-pointer bg-grey-100 p-10 text-center">
{uploading ? 'Uploading ...' : 'Select a JSON or zip file'}
</div>
</FileUpload>;
};
const MigrationOptions: React.FC = () => {
const {mutateAsync: deleteAllContent} = useDeleteAllContent();
const client = useQueryClient();
const handleImportContent = () => {
NiceModal.show(ConfirmationModal, {
title: 'Import content',
prompt: <ImportModalContent />,
okLabel: '',
formSheet: false
});
};
const handleDeleteAllContent = () => {
NiceModal.show(ConfirmationModal, {
title: 'Would you really like to delete all content from your blog?',
prompt: 'This is permanent! No backups, no restores, no magic undo button. We warned you, k?',
okColor: 'red',
okLabel: 'Delete',
onOk: async () => {
await deleteAllContent(null);
showToast({
type: 'success',
message: 'All content deleted from database.'
});
await client.refetchQueries();
}
});
};
return (
<List titleSeparator={false}>
<LabItem
action={<Button color='grey' label='Open importer' size='sm' onClick={handleImportContent} />}
detail='Import posts from a JSON or zip file'
title='Import content' />
<LabItem
action={<Button color='grey' label='Export' size='sm' onClick={() => downloadAllContent()} />}
detail='Download all of your posts and settings in a single, glorious JSON file'
title='Export your content' />
<LabItem
action={<Button color='red' label='Delete' size='sm' onClick={handleDeleteAllContent} />}
detail='Permanently delete all posts and tags from the database, a hard reset'
title='Delete all content' />
</List>
);
};
export default MigrationOptions;

View File

@ -5,6 +5,7 @@ import React from 'react';
import SettingSection from '../../../admin-x-ds/settings/SettingSection';
import Tiers from './Tiers';
import TipsOrDonations from './TipsOrDonations';
import useFeatureFlag from '../../../hooks/useFeatureFlag';
const searchKeywords = {
portal: ['portal', 'signup', 'sign up', 'signin', 'sign in', 'login', 'account', 'membership'],
@ -15,12 +16,14 @@ const searchKeywords = {
};
const MembershipSettings: React.FC = () => {
const hasTipsAndDonations = useFeatureFlag('tipsAndDonations');
return (
<SettingSection keywords={Object.values(searchKeywords).flat()} title='Membership'>
<Portal keywords={searchKeywords.portal} />
<Access keywords={searchKeywords.access} />
<Tiers keywords={searchKeywords.tiers} />
<TipsOrDonations keywords={searchKeywords.tips} />
{hasTipsAndDonations && <TipsOrDonations keywords={searchKeywords.tips} />}
<Analytics keywords={searchKeywords.analytics} />
</SettingSection>
);

View File

@ -0,0 +1,9 @@
import {useGlobalData} from '../components/providers/GlobalDataProvider';
const useFeatureFlag = (flag: string) => {
const {config} = useGlobalData();
return config.labs[flag] || false;
};
export default useFeatureFlag;

View File

@ -63,6 +63,10 @@ export function downloadFile(url: string) {
iframe.setAttribute('src', url);
}
export function downloadFromEndpoint(path: string) {
downloadFile(`${getGhostPaths().apiRoot}${path}`);
}
export function numberWithCommas(x: number) {
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}

View File

@ -83,7 +83,7 @@ test.describe('Zapier integration settings', async () => {
...globalDataRequests.browseConfig,
response: {
config: {
...responseFixtures.config,
...responseFixtures.config.config,
hostSettings: {
limits: {
customIntegrations: {

View File

@ -51,7 +51,7 @@ test.describe('Stripe settings', async () => {
...globalDataRequests,
browseConfig: {method: 'GET', path: '/config/', response: {
config: {
...responseFixtures.config,
...responseFixtures.config.config,
stripeDirect: true
}
}},