Update migration in settings (#19278)
refs.
7b40393d77
We're improving the usability and possibilities for publishers to
migrate from other platforms such as Substack, Medium or Mailchimp. This
PR applies changes to Ghost Settings to support the new flows, more
specifically:
- moves import and export functions out of Labs to its own setting,
directly available from search and the menu
- adds direct access to various platform migrations
- moves "Delete all content" to a dedicated setting group at the bottom
of all setting
---------
Co-authored-by: Jono Mingard <reason.koan@gmail.com>
This commit is contained in:
parent
f23767fe12
commit
58d9b8e382
1
apps/admin-x-design-system/src/assets/icons/download.svg
Normal file
1
apps/admin-x-design-system/src/assets/icons/download.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg viewBox="-0.75 -0.75 24 24" xmlns="http://www.w3.org/2000/svg" height="24" width="24"><path d="m11.2509375 3.515625 0 11.25" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path><path d="m7.0321875 10.546875 4.21875 4.21875 4.21875 -4.21875" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path><path d="M21.797812500000003 14.765625v1.40625a2.8125 2.8125 0 0 1 -2.8125 2.8125h-15.46875a2.8125 2.8125 0 0 1 -2.8125 -2.8125v-1.40625" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path></svg>
|
After Width: | Height: | Size: 659 B |
1
apps/admin-x-design-system/src/assets/icons/export.svg
Normal file
1
apps/admin-x-design-system/src/assets/icons/export.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-0.75 -0.75 24 24" height="24" width="24"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M18.09646875 20.3938125c0.674625 0 1.219125 -0.54459375 1.219125 -1.21921875V5.666521875c0 -0.325096875 -0.13003125 -0.6420750000000001 -0.36571875 -0.8696531249999999l-2.43825 -2.34075c-0.227625 -0.227578125 -0.5364375 -0.349490625 -0.85340625 -0.349490625H4.4042625c-0.674596875 0 -1.21914375 0.544546875 -1.21914375 1.21914375V19.17459375c0 0.674625 0.544546875 1.21921875 1.21914375 1.21921875H18.09646875Z" stroke-width="1.5"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="m8.476865625 12.861375 2.774446875 2.77453125 2.77453125 -2.77453125" stroke-width="1.5"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="m11.2490625 15.63534375 0 -8.770715625" stroke-width="1.5"></path></svg>
|
After Width: | Height: | Size: 943 B |
1
apps/admin-x-design-system/src/assets/icons/import.svg
Normal file
1
apps/admin-x-design-system/src/assets/icons/import.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="-0.75 -0.75 24 24" height="24" width="24"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M18.09553125 20.3938125c0.674625 0 1.21921875 -0.54459375 1.21921875 -1.21921875V5.666521875c0 -0.325096875 -0.13012500000000002 -0.6420750000000001 -0.3658125 -0.8696531249999999l-2.43825 -2.34075c-0.227625 -0.227578125 -0.5364375 -0.349490625 -0.85340625 -0.349490625H4.40334375c-0.6745875 0 -1.21914375 0.544546875 -1.21914375 1.21914375V19.17459375c0 0.674625 0.5445562500000001 1.21921875 1.21914375 1.21921875h13.692187500000001Z" stroke-width="1.5"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="m8.47595625 9.638625 2.7744187499999997 -2.774559375L14.025 9.638625" stroke-width="1.5"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="m11.248125 6.864684375 0 8.770659375000001" stroke-width="1.5"></path></svg>
|
After Width: | Height: | Size: 972 B |
@ -30,6 +30,7 @@ import {SettingsResponseType} from '../api/settings';
|
||||
import {ThemesResponseType} from '../api/themes';
|
||||
import {TiersResponseType} from '../api/tiers';
|
||||
import {UsersResponseType} from '../api/users';
|
||||
import {ExternalLink} from '../routing';
|
||||
|
||||
interface MockRequestConfig {
|
||||
method: string;
|
||||
@ -257,3 +258,7 @@ export async function testUrlValidation(input: Locator, textToEnter: string, exp
|
||||
await expect(input.locator('xpath=../..')).toContainText(expectedError);
|
||||
}
|
||||
};
|
||||
|
||||
export async function expectExternalNavigate(page: Page, link: Partial<ExternalLink>) {
|
||||
await page.waitForURL(`/external/${encodeURIComponent(JSON.stringify({isExternal: true, ...link}))}`);
|
||||
};
|
||||
|
@ -3,7 +3,7 @@ import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import {TopLevelFrameworkProps} from '../providers/FrameworkProvider';
|
||||
|
||||
export default function renderStandaloneApp<Props extends Record<string, never>>(
|
||||
export default function renderStandaloneApp<Props extends object>(
|
||||
App: React.ComponentType<Props & {
|
||||
framework: TopLevelFrameworkProps;
|
||||
designSystem: DesignSystemAppProps;
|
||||
@ -38,7 +38,10 @@ export default function renderStandaloneApp<Props extends Record<string, never>>
|
||||
<App
|
||||
designSystem={{darkMode: false, fetchKoenigLexical: async () => {}}}
|
||||
framework={{
|
||||
externalNavigate: () => {},
|
||||
externalNavigate: (link) => {
|
||||
// Use the expectExternalNavigate helper to test this dummy external linking
|
||||
window.location.href = `/external/${encodeURIComponent(JSON.stringify(link))}`;
|
||||
},
|
||||
ghostVersion: '5.x',
|
||||
sentryDSN: null,
|
||||
unsplashConfig: {
|
||||
|
9
apps/admin-x-settings/src/assets/icons/mailchimp.svg
Normal file
9
apps/admin-x-settings/src/assets/icons/mailchimp.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 17 KiB |
10
apps/admin-x-settings/src/assets/icons/medium.svg
Normal file
10
apps/admin-x-settings/src/assets/icons/medium.svg
Normal file
@ -0,0 +1,10 @@
|
||||
<svg width="260" height="150" viewBox="0 0 260 150" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_480_8)">
|
||||
<path d="M146.656 75.0013C146.656 116.422 113.825 150 73.3291 150C32.8329 150 0 116.414 0 75.0013C0 33.5881 32.8304 0 73.3291 0C113.828 0 146.656 33.5805 146.656 75.0013ZM227.097 75.0013C227.097 113.99 210.682 145.609 190.433 145.609C170.184 145.609 153.768 113.99 153.768 75.0013C153.768 36.0125 170.181 4.39338 190.43 4.39338C210.68 4.39338 227.095 36.0024 227.095 75.0013M260 75.0013C260 109.926 254.228 138.255 247.105 138.255C239.982 138.255 234.213 109.933 234.213 75.0013C234.213 40.0693 239.985 11.7477 247.105 11.7477C254.225 11.7477 260 40.0668 260 75.0013Z" fill="black"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_480_8">
|
||||
<rect width="260" height="150" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 831 B |
5
apps/admin-x-settings/src/assets/icons/substack.svg
Normal file
5
apps/admin-x-settings/src/assets/icons/substack.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg width="168" height="200" viewBox="0 0 168 200" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M168 44.4445H0V66.6667H168V44.4445Z" fill="#FF6719"/>
|
||||
<path d="M0 88.8889V200L83.9927 150.378L168 200V88.8889H0Z" fill="#FF6719"/>
|
||||
<path d="M168 0H0V22.2222H168V0Z" fill="#FF6719"/>
|
||||
</svg>
|
After Width: | Height: | Size: 298 B |
@ -159,6 +159,7 @@ const Sidebar: React.FC = () => {
|
||||
|
||||
<SettingNavSection isVisible={checkVisible(Object.values(advancedSearchKeywords).flat())} title="Advanced">
|
||||
<NavItem icon='modules-3' keywords={advancedSearchKeywords.integrations} navid='integrations' title="Integrations" onClick={handleSectionClick} />
|
||||
<NavItem icon='download' keywords={advancedSearchKeywords.migrationtools} navid='migration' title="Import/Export" onClick={handleSectionClick} />
|
||||
<NavItem icon='brackets' keywords={advancedSearchKeywords.codeInjection} navid='code-injection' title="Code injection" onClick={handleSectionClick} />
|
||||
<NavItem icon='labs-flask' keywords={advancedSearchKeywords.labs} navid='labs' title="Labs" onClick={handleSectionClick} />
|
||||
<NavItem icon='time-back' keywords={advancedSearchKeywords.history} navid='history' title="History" onClick={handleSectionClick} />
|
||||
|
@ -1,24 +1,30 @@
|
||||
import CodeInjection from './CodeInjection';
|
||||
import DangerZone from './DangerZone';
|
||||
import History from './History';
|
||||
import Integrations from './Integrations';
|
||||
import Labs from './Labs';
|
||||
import MigrationTools from './MigrationTools';
|
||||
import React from 'react';
|
||||
import SearchableSection from '../../SearchableSection';
|
||||
|
||||
export const searchKeywords = {
|
||||
integrations: ['advanced', 'integrations', 'zapier', 'slack', 'amp', 'unsplash', 'first promoter', 'firstpromoter', 'pintura', 'disqus', 'analytics', 'ulysses', 'typeform', 'buffer', 'plausible', 'github'],
|
||||
migrationtools: ['import', 'export', 'migrate', 'substack', 'substack', 'migration', 'medium'],
|
||||
codeInjection: ['advanced', 'code injection', 'head', 'footer'],
|
||||
labs: ['advanced', 'labs', 'alpha', 'beta', 'flag', 'import', 'export', 'migrate', 'routes', 'redirect', 'translation', 'delete', 'content', 'editor', 'substack', 'migration', 'portal'],
|
||||
history: ['advanced', 'history', 'log', 'events', 'user events', 'staff']
|
||||
labs: ['advanced', 'labs', 'alpha', 'beta', 'flag', 'routes', 'redirect', 'translation', 'editor', 'portal'],
|
||||
history: ['advanced', 'history', 'log', 'events', 'user events', 'staff'],
|
||||
dangerzone: ['danger', 'danger zone', 'delete', 'content', 'delete all content', 'delete site']
|
||||
};
|
||||
|
||||
const AdvancedSettings: React.FC = () => {
|
||||
return (
|
||||
<SearchableSection keywords={Object.values(searchKeywords).flat()} title='Advanced'>
|
||||
<Integrations keywords={searchKeywords.integrations} />
|
||||
<MigrationTools keywords={searchKeywords.migrationtools} />
|
||||
<CodeInjection keywords={searchKeywords.codeInjection} />
|
||||
<Labs keywords={searchKeywords.labs} />
|
||||
<History keywords={searchKeywords.history} />
|
||||
<DangerZone keywords={searchKeywords.dangerzone} />
|
||||
</SearchableSection>
|
||||
);
|
||||
};
|
||||
|
@ -0,0 +1,52 @@
|
||||
import NiceModal from '@ebay/nice-modal-react';
|
||||
import React from 'react';
|
||||
import TopLevelGroup from '../../TopLevelGroup';
|
||||
import {Button, ConfirmationModal, SettingGroupHeader, showToast, withErrorBoundary} from '@tryghost/admin-x-design-system';
|
||||
import {useDeleteAllContent} from '@tryghost/admin-x-framework/api/db';
|
||||
import {useHandleError} from '@tryghost/admin-x-framework/hooks';
|
||||
import {useQueryClient} from '@tryghost/admin-x-framework';
|
||||
|
||||
const DangerZone: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||
const {mutateAsync: deleteAllContent} = useDeleteAllContent();
|
||||
const client = useQueryClient();
|
||||
const handleError = useHandleError();
|
||||
|
||||
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 (modal) => {
|
||||
try {
|
||||
await deleteAllContent(null);
|
||||
showToast({
|
||||
type: 'success',
|
||||
message: 'All content deleted from database.'
|
||||
});
|
||||
modal?.remove();
|
||||
await client.refetchQueries();
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<TopLevelGroup
|
||||
customHeader={
|
||||
<SettingGroupHeader description='Permanently delete all posts and tags from the database, a hard reset' title='Danger zone' />
|
||||
}
|
||||
keywords={keywords}
|
||||
navid='dangerzone'
|
||||
testId='dangerzone'
|
||||
>
|
||||
<div>
|
||||
<Button color='red' label='Delete all content' onClick={handleDeleteAllContent} />
|
||||
</div>
|
||||
</TopLevelGroup>
|
||||
);
|
||||
};
|
||||
|
||||
export default withErrorBoundary(DangerZone, 'Danger zone');
|
@ -1,25 +1,19 @@
|
||||
import AlphaFeatures from './labs/AlphaFeatures';
|
||||
import BetaFeatures from './labs/BetaFeatures';
|
||||
import LabsBubbles from '../../../assets/images/labs-bg.svg';
|
||||
import MigrationOptions from './labs/MigrationOptions';
|
||||
import React, {useState} from 'react';
|
||||
import TopLevelGroup from '../../TopLevelGroup';
|
||||
import {Button, SettingGroupHeader, Tab, TabView, withErrorBoundary} from '@tryghost/admin-x-design-system';
|
||||
import {useGlobalData} from '../../providers/GlobalDataProvider';
|
||||
|
||||
type LabsTab = 'labs-migration-options' | 'labs-alpha-features' | 'labs-beta-features';
|
||||
type LabsTab = 'labs-alpha-features' | 'labs-beta-features';
|
||||
|
||||
const Labs: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||
const [selectedTab, setSelectedTab] = useState<LabsTab>('labs-migration-options');
|
||||
const [selectedTab, setSelectedTab] = useState<LabsTab>('labs-beta-features');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const {config} = useGlobalData();
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: 'labs-migration-options',
|
||||
title: 'Migration options',
|
||||
contents: <MigrationOptions />
|
||||
},
|
||||
{
|
||||
id: 'labs-beta-features',
|
||||
title: 'Beta features',
|
||||
@ -54,7 +48,7 @@ const Labs: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||
testId='labs'
|
||||
>
|
||||
{isOpen ?
|
||||
<TabView<'labs-migration-options' | 'labs-alpha-features' | 'labs-beta-features'> selectedTab={selectedTab} tabs={tabs} onTabChange={setSelectedTab} />
|
||||
<TabView<'labs-alpha-features' | 'labs-beta-features'> selectedTab={selectedTab} tabs={tabs} onTabChange={setSelectedTab} />
|
||||
:
|
||||
<div className='absolute inset-0 z-0 overflow-hidden opacity-70'>
|
||||
<img className='absolute -right-6 -top-6 dark:opacity-10' src={LabsBubbles} />
|
||||
|
@ -0,0 +1,39 @@
|
||||
import MigrationToolsExport from './migrationtools/MigrationToolsExport';
|
||||
import MigrationToolsImport from './migrationtools/MigrationToolsImport';
|
||||
import React, {useState} from 'react';
|
||||
import TopLevelGroup from '../../TopLevelGroup';
|
||||
import {SettingGroupHeader, Tab, TabView, withErrorBoundary} from '@tryghost/admin-x-design-system';
|
||||
|
||||
type MigrationTab = 'import' | 'export';
|
||||
|
||||
const MigrationTools: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||
const [selectedTab, setSelectedTab] = useState<MigrationTab>('import');
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: 'import',
|
||||
title: 'Import',
|
||||
contents: <MigrationToolsImport />
|
||||
},
|
||||
{
|
||||
id: 'export',
|
||||
title: 'Export',
|
||||
contents: <MigrationToolsExport />
|
||||
}
|
||||
].filter(Boolean) as Tab<MigrationTab>[];
|
||||
|
||||
return (
|
||||
<TopLevelGroup
|
||||
customHeader={
|
||||
<SettingGroupHeader description='Import content, members and subscriptions from other platforms or export your Ghost data.' title='Migration tools' />
|
||||
}
|
||||
keywords={keywords}
|
||||
navid='migration'
|
||||
testId='migrationtools'
|
||||
>
|
||||
<TabView<'import' | 'export'> selectedTab={selectedTab} tabs={tabs} onTabChange={setSelectedTab} />
|
||||
</TopLevelGroup>
|
||||
);
|
||||
};
|
||||
|
||||
export default withErrorBoundary(MigrationTools, 'Migration tools');
|
@ -5,10 +5,8 @@ import {Button, FileUpload, List, showToast} from '@tryghost/admin-x-design-syst
|
||||
import {downloadRedirects, useUploadRedirects} from '@tryghost/admin-x-framework/api/redirects';
|
||||
import {downloadRoutes, useUploadRoutes} from '@tryghost/admin-x-framework/api/routes';
|
||||
import {useHandleError} from '@tryghost/admin-x-framework/hooks';
|
||||
import {useRouting} from '@tryghost/admin-x-framework/routing';
|
||||
|
||||
const BetaFeatures: React.FC = () => {
|
||||
const {updateRoute} = useRouting();
|
||||
const {mutateAsync: uploadRedirects} = useUploadRedirects();
|
||||
const {mutateAsync: uploadRoutes} = useUploadRoutes();
|
||||
const handleError = useHandleError();
|
||||
@ -17,10 +15,6 @@ const BetaFeatures: React.FC = () => {
|
||||
|
||||
return (
|
||||
<List titleSeparator={false}>
|
||||
<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>). Don’t 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></>}
|
||||
|
@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import {Button} from '@tryghost/admin-x-design-system';
|
||||
import {downloadAllContent} from '@tryghost/admin-x-framework/api/db';
|
||||
|
||||
const MigrationToolsExport: React.FC = () => {
|
||||
return (
|
||||
<div className='flex flex-col items-center gap-3 pb-5 pt-10'>
|
||||
<div>Download all of your <strong>posts and settings</strong> in a single, glorious JSON file.</div>
|
||||
<Button className='!h-9 !font-semibold' color='grey' icon='export' iconColorClass='!h-5 !w-auto' label='Export content' onClick={() => downloadAllContent()} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MigrationToolsExport;
|
@ -0,0 +1,76 @@
|
||||
import NiceModal from '@ebay/nice-modal-react';
|
||||
import React from 'react';
|
||||
import UniversalImportModal from './UniversalImportModal';
|
||||
import clsx from 'clsx';
|
||||
import {Icon} from '@tryghost/admin-x-design-system';
|
||||
import {ReactComponent as MailchimpIcon} from '../../../../assets/icons/mailchimp.svg';
|
||||
import {ReactComponent as MediumIcon} from '../../../../assets/icons/medium.svg';
|
||||
import {ReactComponent as SubstackIcon} from '../../../../assets/icons/substack.svg';
|
||||
import {useRouting} from '@tryghost/admin-x-framework/routing';
|
||||
|
||||
const ImportButton: React.FC<{
|
||||
icon?: React.ReactNode,
|
||||
title?: string,
|
||||
onClick?: () => void
|
||||
}> = ({
|
||||
icon,
|
||||
title,
|
||||
onClick
|
||||
}) => {
|
||||
const classNames = clsx(
|
||||
'flex h-9 cursor-pointer items-center justify-center gap-2 rounded-md bg-grey-100 px-2 text-sm font-semibold transition-all hover:bg-grey-200 dark:bg-grey-900'
|
||||
);
|
||||
if (onClick) {
|
||||
return (
|
||||
<button className={classNames} type='button' onClick={onClick}>
|
||||
{icon}
|
||||
{title}
|
||||
</button>
|
||||
);
|
||||
} else {
|
||||
return <></>;
|
||||
}
|
||||
};
|
||||
|
||||
const MigrationToolsImport: React.FC = () => {
|
||||
const {updateRoute} = useRouting();
|
||||
|
||||
const handleImportContent = () => {
|
||||
NiceModal.show(UniversalImportModal);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='grid grid-cols-1 gap-4 pt-4 md:grid-cols-2 lg:grid-cols-3'>
|
||||
<ImportButton
|
||||
icon={
|
||||
<SubstackIcon className='h-[18px] w-auto' />
|
||||
}
|
||||
title='Substack'
|
||||
onClick={() => updateRoute({isExternal: true, route: '/migrate/substack'})}
|
||||
/>
|
||||
<ImportButton
|
||||
icon={
|
||||
<MediumIcon className='h-[18px] w-auto dark:invert' />
|
||||
}
|
||||
title='Medium'
|
||||
onClick={() => updateRoute({isExternal: true, route: '/migrate/medium'})}
|
||||
/>
|
||||
<ImportButton
|
||||
icon={
|
||||
<MailchimpIcon className='h-5 w-auto' />
|
||||
}
|
||||
title='Mailchimp'
|
||||
onClick={() => updateRoute({isExternal: true, route: '/migrate/mailchimp'})}
|
||||
/>
|
||||
<ImportButton
|
||||
icon={
|
||||
<Icon className='h-4 w-auto' name='import' />
|
||||
}
|
||||
title='Universal import'
|
||||
onClick={handleImportContent}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MigrationToolsImport;
|
@ -0,0 +1,55 @@
|
||||
import NiceModal, {useModal} from '@ebay/nice-modal-react';
|
||||
import React, {useState} from 'react';
|
||||
import {ConfirmationModal, FileUpload, Modal} from '@tryghost/admin-x-design-system';
|
||||
import {useHandleError} from '@tryghost/admin-x-framework/hooks';
|
||||
import {useImportContent} from '@tryghost/admin-x-framework/api/db';
|
||||
|
||||
const UniversalImportModal: React.FC = () => {
|
||||
const modal = useModal();
|
||||
const {mutateAsync: importContent} = useImportContent();
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const handleError = useHandleError();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
backDropClick={false}
|
||||
okLabel=''
|
||||
size='sm'
|
||||
testId='universal-import-modal'
|
||||
title='Universal import'
|
||||
>
|
||||
<div className='py-4 leading-9'>
|
||||
<FileUpload
|
||||
id="import-file"
|
||||
onUpload={async (file) => {
|
||||
setUploading(true);
|
||||
try {
|
||||
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
|
||||
});
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="-mb-4 cursor-pointer bg-grey-75 p-10 text-center dark:bg-grey-950">
|
||||
{uploading ? 'Uploading...' : <>
|
||||
Select any JSON or zip file that contains <br />posts and settings
|
||||
</>}
|
||||
</div>
|
||||
</FileUpload>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default NiceModal.create(UniversalImportModal);
|
@ -1,63 +1,46 @@
|
||||
import './styles/demo.css';
|
||||
import './styles/index.css';
|
||||
import App from './App.tsx';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import {DefaultHeaderTypes} from './unsplash/UnsplashTypes.ts';
|
||||
import renderStandaloneApp from '@tryghost/admin-x-framework/test/render';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App
|
||||
designSystem={{darkMode: false, fetchKoenigLexical: async () => {}}}
|
||||
framework={{
|
||||
externalNavigate: () => {},
|
||||
ghostVersion: '5.x',
|
||||
sentryDSN: null,
|
||||
unsplashConfig: {} as DefaultHeaderTypes,
|
||||
onDelete: () => {},
|
||||
onInvalidate: () => {},
|
||||
onUpdate: () => {}
|
||||
}}
|
||||
officialThemes={[{
|
||||
name: 'Source',
|
||||
category: 'News',
|
||||
previewUrl: 'https://source.ghost.io/',
|
||||
ref: 'default',
|
||||
image: 'assets/img/themes/Source.png',
|
||||
variants: [
|
||||
{
|
||||
category: 'Magazine',
|
||||
previewUrl: 'https://source-magazine.ghost.io/',
|
||||
image: 'assets/img/themes/Source-Magazine.png'
|
||||
},
|
||||
{
|
||||
category: 'Newsletter',
|
||||
previewUrl: 'https://source-newsletter.ghost.io/',
|
||||
image: 'assets/img/themes/Source-Newsletter.png'
|
||||
}
|
||||
]
|
||||
}, {
|
||||
name: 'Casper',
|
||||
category: 'Blog',
|
||||
previewUrl: 'https://demo.ghost.io/',
|
||||
ref: 'default',
|
||||
image: 'assets/img/themes/Casper.png'
|
||||
}, {
|
||||
name: 'Headline',
|
||||
category: 'News',
|
||||
url: 'https://github.com/TryGhost/Headline',
|
||||
previewUrl: 'https://headline.ghost.io',
|
||||
ref: 'TryGhost/Headline',
|
||||
image: 'assets/img/themes/Headline.png'
|
||||
}, {
|
||||
name: 'Edition',
|
||||
renderStandaloneApp(App, {
|
||||
officialThemes: [{
|
||||
name: 'Source',
|
||||
category: 'News',
|
||||
previewUrl: 'https://source.ghost.io/',
|
||||
ref: 'default',
|
||||
image: 'assets/img/themes/Source.png',
|
||||
variants: [
|
||||
{
|
||||
category: 'Magazine',
|
||||
previewUrl: 'https://source-magazine.ghost.io/',
|
||||
image: 'assets/img/themes/Source-Magazine.png'
|
||||
},
|
||||
{
|
||||
category: 'Newsletter',
|
||||
url: 'https://github.com/TryGhost/Edition',
|
||||
previewUrl: 'https://edition.ghost.io/',
|
||||
ref: 'TryGhost/Edition',
|
||||
image: 'assets/img/themes/Edition.png'
|
||||
}]}
|
||||
zapierTemplates={[]}
|
||||
/>
|
||||
</React.StrictMode>
|
||||
);
|
||||
previewUrl: 'https://source-newsletter.ghost.io/',
|
||||
image: 'assets/img/themes/Source-Newsletter.png'
|
||||
}
|
||||
]
|
||||
}, {
|
||||
name: 'Casper',
|
||||
category: 'Blog',
|
||||
previewUrl: 'https://demo.ghost.io/',
|
||||
ref: 'default',
|
||||
image: 'assets/img/themes/Casper.png'
|
||||
}, {
|
||||
name: 'Headline',
|
||||
category: 'News',
|
||||
url: 'https://github.com/TryGhost/Headline',
|
||||
previewUrl: 'https://headline.ghost.io',
|
||||
ref: 'TryGhost/Headline',
|
||||
image: 'assets/img/themes/Headline.png'
|
||||
}, {
|
||||
name: 'Edition',
|
||||
category: 'Newsletter',
|
||||
url: 'https://github.com/TryGhost/Edition',
|
||||
previewUrl: 'https://edition.ghost.io/',
|
||||
ref: 'TryGhost/Edition',
|
||||
image: 'assets/img/themes/Edition.png'
|
||||
}],
|
||||
zapierTemplates: []
|
||||
});
|
||||
|
@ -1,18 +0,0 @@
|
||||
:root {
|
||||
font-size: 62.5%;
|
||||
line-height: 1.5;
|
||||
-ms-text-size-adjust: 100%;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
letter-spacing: unset;
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
import {expect, test} from '@playwright/test';
|
||||
import {globalDataRequests} from '../../utils/acceptance';
|
||||
import {mockApi} from '@tryghost/admin-x-framework/test/acceptance';
|
||||
|
||||
test.describe('DangerZone', async () => {
|
||||
test('Delete all content', async ({page}) => {
|
||||
const {lastApiRequests} = await mockApi({page, requests: {
|
||||
...globalDataRequests,
|
||||
deleteAllContent: {method: 'DELETE', path: '/db/', response: {}}
|
||||
}});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
const dangeZoneSection = page.getByTestId('dangerzone');
|
||||
|
||||
await dangeZoneSection.getByRole('button', {name: 'Delete all content'}).click();
|
||||
|
||||
await page.getByTestId('confirmation-modal').getByRole('button', {name: 'Delete'}).click();
|
||||
|
||||
await expect(page.getByTestId('toast-success')).toContainText('All content deleted');
|
||||
|
||||
expect(lastApiRequests.deleteAllContent).toBeTruthy();
|
||||
});
|
||||
});
|
@ -3,26 +3,6 @@ import {globalDataRequests} from '../../utils/acceptance';
|
||||
import {mockApi} from '@tryghost/admin-x-framework/test/acceptance';
|
||||
|
||||
test.describe('Labs', async () => {
|
||||
test('Delete all content', async ({page}) => {
|
||||
const {lastApiRequests} = await mockApi({page, requests: {
|
||||
...globalDataRequests,
|
||||
deleteAllContent: {method: 'DELETE', path: '/db/', response: {}}
|
||||
}});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
const labsSection = page.getByTestId('labs');
|
||||
|
||||
await labsSection.getByRole('button', {name: 'Open'}).click();
|
||||
await labsSection.getByRole('button', {name: 'Delete'}).click();
|
||||
|
||||
await page.getByTestId('confirmation-modal').getByRole('button', {name: 'Delete'}).click();
|
||||
|
||||
await expect(page.getByTestId('toast-success')).toContainText('All content deleted');
|
||||
|
||||
expect(lastApiRequests.deleteAllContent).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Uploading/downloading redirects', async ({page}) => {
|
||||
const {lastApiRequests} = await mockApi({page, requests: {
|
||||
...globalDataRequests,
|
||||
|
@ -0,0 +1,80 @@
|
||||
import {expect, test} from '@playwright/test';
|
||||
import {expectExternalNavigate, mockApi} from '@tryghost/admin-x-framework/test/acceptance';
|
||||
import {globalDataRequests} from '../../utils/acceptance';
|
||||
|
||||
test.describe('Migration tools', async () => {
|
||||
test('Built-in migrators', async ({page}) => {
|
||||
await mockApi({page, requests: {
|
||||
...globalDataRequests
|
||||
}});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
const migrationSection = page.getByTestId('migrationtools');
|
||||
|
||||
await migrationSection.getByRole('button', {name: 'Substack'}).click();
|
||||
await expectExternalNavigate(page, {route: '/migrate/substack'});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await migrationSection.getByRole('button', {name: 'Medium'}).click();
|
||||
await expectExternalNavigate(page, {route: '/migrate/medium'});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await migrationSection.getByRole('button', {name: 'Mailchimp'}).click();
|
||||
await expectExternalNavigate(page, {route: '/migrate/mailchimp'});
|
||||
});
|
||||
|
||||
test('Universal import', async ({page}) => {
|
||||
const {lastApiRequests} = await mockApi({page, requests: {
|
||||
...globalDataRequests,
|
||||
importContent: {path: '/db/', method: 'POST', response: {}}
|
||||
}});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
const migrationSection = page.getByTestId('migrationtools');
|
||||
|
||||
await migrationSection.getByRole('button', {name: 'Universal import'}).click();
|
||||
|
||||
const universalImportModal = page.getByTestId('universal-import-modal');
|
||||
|
||||
const fileChooserPromise = page.waitForEvent('filechooser');
|
||||
|
||||
universalImportModal.getByText(/JSON or zip file/).click();
|
||||
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(`${__dirname}/../../utils/files/upload.zip`);
|
||||
|
||||
const confirmationModal = page.getByTestId('confirmation-modal');
|
||||
|
||||
await expect(confirmationModal).toContainText('Import in progress');
|
||||
|
||||
await confirmationModal.getByRole('button', {name: 'Got it'}).click();
|
||||
|
||||
await expect(universalImportModal).not.toBeVisible();
|
||||
await expect(confirmationModal).not.toBeVisible();
|
||||
|
||||
expect(lastApiRequests.importContent).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Content export', async ({page}) => {
|
||||
const {lastApiRequests} = await mockApi({page, requests: {
|
||||
...globalDataRequests,
|
||||
downloadAllContent: {path: '/db/', method: 'GET', response: {}}
|
||||
}});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
const migrationSection = page.getByTestId('migrationtools');
|
||||
|
||||
await migrationSection.getByRole('tab', {name: 'Export'}).click();
|
||||
|
||||
await migrationSection.getByRole('button', {name: 'Export content'}).click();
|
||||
|
||||
await expect(page.locator('iframe#iframeDownload')).toHaveAttribute('src', /\/db\/$/);
|
||||
|
||||
expect(lastApiRequests.downloadAllContent).toBeTruthy();
|
||||
});
|
||||
});
|
BIN
apps/admin-x-settings/test/utils/files/upload.zip
Normal file
BIN
apps/admin-x-settings/test/utils/files/upload.zip
Normal file
Binary file not shown.
@ -12,6 +12,6 @@ export default class MigrateController extends Controller {
|
||||
|
||||
@action
|
||||
closeMigrate() {
|
||||
this.router.transitionTo('/settings/labs');
|
||||
this.router.transitionTo('/settings/migration');
|
||||
}
|
||||
}
|
||||
|
@ -71,7 +71,9 @@ Router.map(function () {
|
||||
});
|
||||
});
|
||||
|
||||
this.route('migrate');
|
||||
this.route('migrate', function () {
|
||||
this.route('migrate', {path: '/*platform'});
|
||||
});
|
||||
|
||||
this.route('members', function () {
|
||||
this.route('import');
|
||||
|
@ -15,6 +15,7 @@ export default class MigrateService extends Service {
|
||||
@tracked siteData = null;
|
||||
@tracked previousRoute = null;
|
||||
@tracked isIframeTransition = false;
|
||||
@tracked platform = null;
|
||||
|
||||
get apiUrl() {
|
||||
const origin = window.location.origin;
|
||||
@ -71,6 +72,10 @@ export default class MigrateService extends Service {
|
||||
|
||||
getIframeURL() {
|
||||
let url = this.migrateUrl;
|
||||
const params = this.router.currentRoute.params;
|
||||
if (params.platform) {
|
||||
url = url + '?platform=' + params.platform;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user