0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-04-08 02:52:39 -05:00

Updated portal popup to support selecting and updating tiers (#17192)

refs https://github.com/TryGhost/Team/issues/3545
This commit is contained in:
Jono M 2023-07-04 19:17:42 +12:00 committed by GitHub
parent 7f9f467fc6
commit 1cc55eda2e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 273 additions and 33 deletions

View file

@ -19,8 +19,7 @@ type Story = StoryObj<typeof Checkbox>;
export const Default: Story = {
args: {
label: 'Checkbox 1',
id: 'my-radio-button'
label: 'Checkbox 1'
}
};

View file

@ -1,21 +1,22 @@
import Heading from '../Heading';
import Hint from '../Hint';
import React, {useEffect, useState} from 'react';
import React, {useEffect, useId, useState} from 'react';
import Separator from '../Separator';
interface CheckboxProps {
id: string;
title?: string;
label: string;
value: string;
onChange: (checked: boolean) => void;
error?:boolean;
disabled?: boolean;
error?: boolean;
hint?: React.ReactNode;
checked?: boolean;
separator?: boolean;
}
const Checkbox: React.FC<CheckboxProps> = ({id, title, label, value, onChange, error, hint, checked, separator}) => {
const Checkbox: React.FC<CheckboxProps> = ({title, label, value, onChange, disabled, error, hint, checked, separator}) => {
const id = useId();
const [isChecked, setIsChecked] = useState(checked);
useEffect(() => {
@ -36,6 +37,7 @@ const Checkbox: React.FC<CheckboxProps> = ({id, title, label, value, onChange, e
<input
checked={isChecked}
className="relative float-left mt-[3px] h-4 w-4 appearance-none border-2 border-solid border-grey-300 outline-none checked:border-green checked:bg-green checked:after:absolute checked:after:-mt-px checked:after:ml-[3px] checked:after:block checked:after:h-[11px] checked:after:w-[6px] checked:after:rotate-45 checked:after:border-[2px] checked:after:border-l-0 checked:after:border-t-0 checked:after:border-solid checked:after:border-white checked:after:bg-transparent checked:after:content-[''] hover:cursor-pointer focus:shadow-none focus:transition-[border-color_0.2s] dark:border-grey-600 dark:checked:border-green dark:checked:bg-green"
disabled={disabled}
id={id}
type='checkbox'
value={value}
@ -52,4 +54,4 @@ const Checkbox: React.FC<CheckboxProps> = ({id, title, label, value, onChange, e
);
};
export default Checkbox;
export default Checkbox;

View file

@ -1,7 +1,9 @@
import React, {createContext, useContext, useMemo} from 'react';
import setupGhostApi from '../../utils/api';
import useDataService, {DataService, bulkEdit} from '../../utils/dataService';
import useSearchService, {SearchService} from '../../utils/search';
import {OfficialTheme} from '../../models/themes';
import {Tier} from '../../types/api';
export interface FileService {
uploadImage: (file: File) => Promise<string>;
@ -11,6 +13,7 @@ interface ServicesContextProps {
fileService: FileService|null;
officialThemes: OfficialTheme[];
search: SearchService
tiers: DataService<Tier>
}
interface ServicesProviderProps {
@ -23,7 +26,8 @@ const ServicesContext = createContext<ServicesContextProps>({
api: setupGhostApi({ghostVersion: ''}),
fileService: null,
officialThemes: [],
search: {filter: '', setFilter: () => {}, checkVisible: () => true}
search: {filter: '', setFilter: () => {}, checkVisible: () => true},
tiers: {data: [], update: async () => {}}
});
const ServicesProvider: React.FC<ServicesProviderProps> = ({children, ghostVersion, officialThemes}) => {
@ -35,13 +39,15 @@ const ServicesProvider: React.FC<ServicesProviderProps> = ({children, ghostVersi
}
}), [apiService]);
const search = useSearchService();
const tiers = useDataService({key: 'tiers', browse: apiService.tiers.browse, edit: bulkEdit('tiers', apiService.tiers.edit)});
return (
<ServicesContext.Provider value={{
api: apiService,
fileService,
officialThemes,
search
search,
tiers
}}>
{children}
</ServicesContext.Provider>
@ -57,3 +63,5 @@ export const useApi = () => useServices().api;
export const useOfficialThemes = () => useServices().officialThemes;
export const useSearch = () => useServices().search;
export const useTiers = () => useServices().tiers;

View file

@ -1,12 +1,13 @@
import React, {createContext, useCallback, useContext, useEffect, useState} from 'react';
import {Config, Setting, SiteData} from '../../types/api';
import {ServicesContext} from './ServiceProvider';
import {Setting, SiteData} from '../../types/api';
// Define the Settings Context
interface SettingsContextProps {
settings: Setting[] | null;
saveSettings: (updatedSettings: Setting[]) => Promise<Setting[]>;
siteData: SiteData | null;
config: Config | null;
}
interface SettingsProviderProps {
@ -16,6 +17,7 @@ interface SettingsProviderProps {
const SettingsContext = createContext<SettingsContextProps>({
settings: null,
siteData: null,
config: null,
saveSettings: async () => []
});
@ -79,18 +81,23 @@ function deserializeSettings(settings: Setting[]): Setting[] {
// Create a Settings Provider component
const SettingsProvider: React.FC<SettingsProviderProps> = ({children}) => {
const {api} = useContext(ServicesContext);
const [settings, setSettings] = useState <Setting[] | null> (null);
const [siteData, setSiteData] = useState <SiteData | null> (null);
const [settings, setSettings] = useState<Setting[] | null> (null);
const [siteData, setSiteData] = useState<SiteData | null> (null);
const [config, setConfig] = useState<Config | null> (null);
useEffect(() => {
const fetchSettings = async (): Promise<void> => {
try {
// Make an API call to fetch the settings
const data = await api.settings.browse();
const siteDataRes = await api.site.browse();
const [settingsData, siteDataResponse, configData] = await Promise.all([
api.settings.browse(),
api.site.browse(),
api.config.browse()
]);
setSettings(serialiseSettingsData(data.settings));
setSiteData(siteDataRes.site);
setSettings(serialiseSettingsData(settingsData.settings));
setSiteData(siteDataResponse.site);
setConfig(configData.config);
} catch (error) {
// Log error in settings API
}
@ -120,7 +127,7 @@ const SettingsProvider: React.FC<SettingsProviderProps> = ({children}) => {
// Provide the settings and the saveSettings function to the children components
return (
<SettingsContext.Provider value={{
settings, saveSettings, siteData
settings, saveSettings, siteData, config
}}>
{children}
</SettingsContext.Provider>

View file

@ -2,24 +2,28 @@ import AccountPage from './portal/AccountPage';
import LookAndFeel from './portal/LookAndFeel';
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import PortalPreview from './portal/PortalPreview';
import React, {useState} from 'react';
import React, {useContext, useState} from 'react';
import SignupOptions from './portal/SignupOptions';
import TabView, {Tab} from '../../../admin-x-ds/global/TabView';
import useSettingGroup from '../../../hooks/useSettingGroup';
import useForm, {Dirtyable} from '../../../hooks/useForm';
import {PreviewModalContent} from '../../../admin-x-ds/global/modal/PreviewModal';
import {Setting, SettingValue} from '../../../types/api';
import {Setting, SettingValue, Tier} from '../../../types/api';
import {SettingsContext} from '../../providers/SettingsProvider';
import {useTiers} from '../../providers/ServiceProvider';
const Sidebar: React.FC<{
localSettings: Setting[]
updateSetting: (key: string, setting: SettingValue) => void
}> = ({localSettings, updateSetting}) => {
localTiers: Tier[]
updateTier: (tier: Tier) => void
}> = ({localSettings, updateSetting, localTiers, updateTier}) => {
const [selectedTab, setSelectedTab] = useState('signupOptions');
const tabs: Tab[] = [
{
id: 'signupOptions',
title: 'Signup options',
contents: <SignupOptions localSettings={localSettings} updateSetting={updateSetting} />
contents: <SignupOptions localSettings={localSettings} localTiers={localTiers} updateSetting={updateSetting} updateTier={updateTier} />
},
{
id: 'lookAndFeel',
@ -48,13 +52,44 @@ const PortalModal: React.FC = () => {
const modal = useModal();
const [selectedPreviewTab, setSelectedPreviewTab] = useState('signup');
const {localSettings, updateSetting, handleSave, saveState} = useSettingGroup();
const {settings, saveSettings} = useContext(SettingsContext);
const {data: tiers, update: updateTiers} = useTiers();
const {formState, saveState, handleSave, updateForm} = useForm({
initialState: {
settings: settings as Dirtyable<Setting>[],
tiers: tiers as Dirtyable<Tier>[]
},
onSave: async () => {
await updateTiers(formState.tiers.filter(tier => tier.dirty));
await saveSettings(formState.settings.filter(setting => setting.dirty));
}
});
const updateSetting = (key: string, value: SettingValue) => {
updateForm(state => ({
...state,
settings: state.settings.map(setting => (
setting.key === key ? {...setting, value, dirty: true} : setting
))
}));
};
const updateTier = (newTier: Tier) => {
updateForm(state => ({
...state,
tiers: state.tiers.map(tier => (
tier.id === newTier.id ? {...newTier, dirty: true} : tier
))
}));
};
const onSelectURL = (id: string) => {
setSelectedPreviewTab(id);
};
const sidebar = <Sidebar localSettings={localSettings} updateSetting={updateSetting} />;
const sidebar = <Sidebar localSettings={formState.settings} localTiers={formState.tiers} updateSetting={updateSetting} updateTier={updateTier} />;
const preview = <PortalPreview selectedTab={selectedPreviewTab} />;
let previewTabs: Tab[] = [

View file

@ -1,17 +1,39 @@
import React from 'react';
import Checkbox from '../../../../admin-x-ds/global/form/Checkbox';
import Heading from '../../../../admin-x-ds/global/Heading';
import React, {useContext} from 'react';
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
import {Setting, SettingValue} from '../../../../types/api';
import {getSettingValues} from '../../../../utils/helpers';
import {Setting, SettingValue, Tier} from '../../../../types/api';
import {SettingsContext} from '../../../providers/SettingsProvider';
import {checkStripeEnabled, getSettingValues} from '../../../../utils/helpers';
const SignupOptions: React.FC<{
localSettings: Setting[]
updateSetting: (key: string, setting: SettingValue) => void
}> = ({localSettings, updateSetting}) => {
const [membersSignupAccess, portalName, portalSignupCheckboxRequired] = getSettingValues(localSettings, ['members_signup_access', 'portal_name', 'portal_signup_checkbox_required']);
localTiers: Tier[]
updateTier: (tier: Tier) => void
}> = ({localSettings, updateSetting, localTiers, updateTier}) => {
const {config} = useContext(SettingsContext);
const [membersSignupAccess, portalName, portalSignupCheckboxRequired, portalPlansJson] = getSettingValues(localSettings, ['members_signup_access', 'portal_name', 'portal_signup_checkbox_required', 'portal_plans']);
const portalPlans = JSON.parse(portalPlansJson?.toString() || '[]') as string[];
const togglePlan = (plan: string) => {
const index = portalPlans.indexOf(plan);
if (index === -1) {
portalPlans.push(plan);
} else {
portalPlans.splice(index, 1);
}
updateSetting('portal_plans', JSON.stringify(portalPlans));
};
// This is a bit unclear in current admin, maybe we should add a message if the settings are disabled?
const isDisabled = membersSignupAccess !== 'all';
const isStripeEnabled = checkStripeEnabled(localSettings, config!);
return <>
<Toggle
checked={Boolean(portalName)}
@ -19,7 +41,27 @@ const SignupOptions: React.FC<{
label='Display name in signup form'
onChange={e => updateSetting('portal_name', e.target.checked)}
/>
<div>TODO: Tiers available at signup</div>
<Heading level={6} grey>Tiers available at signup</Heading>
<Checkbox checked={portalPlans.includes('free')} disabled={isDisabled} label='Free' value='free' onChange={() => togglePlan('free')} />
{isStripeEnabled && localTiers.map(tier => (
<Checkbox
checked={tier.visibility === 'public'}
label={tier.name}
value={tier.id}
onChange={checked => updateTier({...tier, visibility: checked ? 'public' : 'none'})}
/>
))}
{isStripeEnabled && localTiers.some(tier => tier.visibility === 'public') && (
<>
<Heading level={6} grey>Prices available at signup</Heading>
<Checkbox checked={portalPlans.includes('monthly')} disabled={isDisabled} label='Monthly' value='monthly' onChange={() => togglePlan('monthly')} />
<Checkbox checked={portalPlans.includes('yearly')} disabled={isDisabled} label='Yearly' value='yearly' onChange={() => togglePlan('yearly')} />
</>
)}
<div>TODO: Display notice at signup (Koenig)</div>
<Toggle
checked={Boolean(portalSignupCheckboxRequired)}

View file

@ -1,5 +1,9 @@
import {useCallback, useEffect, useState} from 'react';
export type Dirtyable<Data> = Data & {
dirty?: boolean;
}
export type SaveState = 'unsaved' | 'saving' | 'saved' | 'error' | '';
export interface FormHook<State> {

View file

@ -5,6 +5,10 @@ export type Setting = {
value: SettingValue;
}
export type Config = {
[key: string]: any;
}
export type User = {
id: string;
name: string;

View file

@ -1,4 +1,4 @@
import {CustomThemeSetting, InstalledTheme, Label, Offer, Post, Setting, SiteData, Theme, Tier, User, UserRole} from '../types/api';
import {Config, CustomThemeSetting, InstalledTheme, Label, Offer, Post, Setting, SiteData, Theme, Tier, User, UserRole} from '../types/api';
import {getGhostPaths} from './helpers';
interface Meta {
@ -17,6 +17,10 @@ export interface SettingsResponseType {
settings: Setting[];
}
export interface ConfigResponseType {
config: Config;
}
export interface UsersResponseType {
meta?: Meta;
users: User[];
@ -124,6 +128,9 @@ export interface API {
browse: () => Promise<SettingsResponseType>;
edit: (newSettings: Setting[]) => Promise<SettingsResponseType>;
};
config: {
browse: () => Promise<ConfigResponseType>;
};
users: {
browse: () => Promise<UsersResponseType>;
currentUser: () => Promise<User>;
@ -161,6 +168,7 @@ export interface API {
};
tiers: {
browse: () => Promise<TiersResponseType>
edit: (newTier: Tier) => Promise<TiersResponseType>
};
labels: {
browse: () => Promise<LabelsResponseType>
@ -230,6 +238,13 @@ function setupGhostApi({ghostVersion}: GhostApiOptions): API {
return data;
}
},
config: {
browse: async () => {
const response = await fetcher(`/config/`, {});
const data: ConfigResponseType = await response.json();
return data;
}
},
users: {
browse: async () => {
const response = await fetcher(`/users/?limit=all&include=roles`, {});
@ -386,6 +401,14 @@ function setupGhostApi({ghostVersion}: GhostApiOptions): API {
const response = await fetcher(`/tiers/?filter=${filter}&limit=all`);
const data: TiersResponseType = await response.json();
return data;
},
edit: async (tier) => {
const response = await fetcher(`/tiers/${tier.id}`, {
method: 'PUT',
body: JSON.stringify({tiers: [tier]})
});
const data: TiersResponseType = await response.json();
return data;
}
},
labels: {

View file

@ -0,0 +1,53 @@
import {useEffect, useState} from 'react';
export interface DataService<Data> {
data: Data[];
update: (data: Data[]) => Promise<void>;
}
// eslint-disable-next-line no-unused-vars
type BulkEditFunction<Data, DataKey extends string> = (newData: Data[]) => Promise<{ [k in DataKey]: Data[] }>
const useDataService = <Data extends { id: string }, DataKey extends string>({key, browse, edit}: {
key: DataKey
// eslint-disable-next-line no-unused-vars
browse: () => Promise<{ [k in DataKey]: Data[] }>
// eslint-disable-next-line no-unused-vars
edit: BulkEditFunction<Data, DataKey>
}): DataService<Data> => {
const [data, setData] = useState<Data[]>([]);
useEffect(() => {
browse().then((response) => {
setData(response[key]);
});
}, [browse, key]);
const update = async (newData: Data[]) => {
const response = await edit(newData);
setData(data.map((item) => {
const replacement = response[key].find(newItem => newItem.id === item.id);
return replacement || item;
}));
};
return {data, update};
};
export default useDataService;
// Utility for APIs which edit one object at a time
export const bulkEdit = <Data extends { id: string }, DataKey extends string>(
key: DataKey,
// eslint-disable-next-line no-unused-vars
updateOne: (data: Data) => Promise<{ [k in DataKey]: Data[] }>
): BulkEditFunction<Data, DataKey> => {
return async (newData: Data[]) => {
const response = await Promise.all(newData.map(updateOne));
return {
[key]: response.reduce((all, current) => all.concat(current[key]), [] as Data[])
// eslint-disable-next-line no-unused-vars
} as { [k in DataKey]: Data[] };
};
};

View file

@ -1,4 +1,4 @@
import {Setting, SettingValue, SiteData, User} from '../types/api';
import {Config, Setting, SettingValue, SiteData, User} from '../types/api';
export interface IGhostPaths {
adminRoot: string;
@ -107,3 +107,16 @@ export function getEmailDomain(siteData: SiteData): string {
}
return domain;
}
export function checkStripeEnabled(settings: Setting[], config: Config) {
const hasSetting = (key: string) => settings.some(setting => setting.key === key && setting.value);
const hasDirectKeys = hasSetting('stripe_secret_key') && hasSetting('stripe_publishable_key');
const hasConnectKeys = hasSetting('stripe_connect_secret_key') && hasSetting('stripe_connect_publishable_key');
if (config.stripeDirect) {
return hasDirectKeys;
}
return hasConnectKeys || hasDirectKeys;
}

View file

@ -1,9 +1,10 @@
import {CustomThemeSettingsResponseType, ImagesResponseType, InvitesResponseType, LabelsResponseType, OffersResponseType, PostsResponseType, RolesResponseType, SettingsResponseType, SiteResponseType, ThemesResponseType, TiersResponseType, UsersResponseType} from '../../src/utils/api';
import {ConfigResponseType, CustomThemeSettingsResponseType, ImagesResponseType, InvitesResponseType, LabelsResponseType, OffersResponseType, PostsResponseType, RolesResponseType, SettingsResponseType, SiteResponseType, ThemesResponseType, TiersResponseType, UsersResponseType} from '../../src/utils/api';
import {Page, Request} from '@playwright/test';
import {readFileSync} from 'fs';
export const responseFixtures = {
settings: JSON.parse(readFileSync(`${__dirname}/responses/settings.json`).toString()) as SettingsResponseType,
config: JSON.parse(readFileSync(`${__dirname}/responses/config.json`).toString()) as ConfigResponseType,
users: JSON.parse(readFileSync(`${__dirname}/responses/users.json`).toString()) as UsersResponseType,
me: JSON.parse(readFileSync(`${__dirname}/responses/me.json`).toString()) as UsersResponseType,
roles: JSON.parse(readFileSync(`${__dirname}/responses/roles.json`).toString()) as RolesResponseType,
@ -21,6 +22,9 @@ interface Responses {
browse?: SettingsResponseType
edit?: SettingsResponseType
}
config?: {
browse?: ConfigResponseType
}
users?: {
browse?: UsersResponseType
currentUser?: UsersResponseType
@ -83,6 +87,9 @@ type LastRequests = {
browse: RequestRecord
edit: RequestRecord
}
config: {
browse: RequestRecord
}
users: {
browse: RequestRecord
currentUser: RequestRecord
@ -137,6 +144,7 @@ type LastRequests = {
export async function mockApi({page,responses}: {page: Page, responses?: Responses}) {
const lastApiRequests: LastRequests = {
settings: {browse: {}, edit: {}},
config: {browse: {}},
users: {browse: {}, currentUser: {}, edit: {}, delete: {}, updatePassword: {}, makeOwner: {}},
roles: {browse: {}},
invites: {browse: {}, add: {}, delete: {}},
@ -166,6 +174,17 @@ export async function mockApi({page,responses}: {page: Page, responses?: Respons
}
});
await mockApiResponse({
page,
path: /\/ghost\/api\/admin\/config\//,
respondTo: {
GET: {
body: responses?.config?.browse ?? responseFixtures.config,
updateLastRequest: lastApiRequests.config.browse
}
}
});
await mockApiResponse({
page,
path: /\/ghost\/api\/admin\/users\/\?/,

View file

@ -0,0 +1,31 @@
{
"config": {
"version": "5.53.3",
"environment": "development",
"database": "sqlite3",
"mail": "SMTP",
"useGravatar": true,
"labs": {},
"clientExtensions": {},
"enableDeveloperExperiments": true,
"stripeDirect": false,
"mailgunIsConfigured": false,
"emailAnalytics": true,
"tenor": {
"googleApiKey": null,
"contentFilter": "off"
},
"editor": {
"url": "http://editor.test/koenig-lexical.umd.js",
"version": ""
},
"adminX": {
"url": "http://admin-x.test/admin-x-settings.umd.js",
"version": "0.0"
},
"signupForm": {
"url": "https://signup-form.test/signup-form.min.js",
"version": "0.1"
}
}
}