From 1cc55eda2e6980dc05f759d6c7665905f59aca15 Mon Sep 17 00:00:00 2001 From: Jono M Date: Tue, 4 Jul 2023 19:17:42 +1200 Subject: [PATCH] Updated portal popup to support selecting and updating tiers (#17192) refs https://github.com/TryGhost/Team/issues/3545 --- .../global/form/Checkbox.stories.tsx | 3 +- .../src/admin-x-ds/global/form/Checkbox.tsx | 12 +++-- .../components/providers/ServiceProvider.tsx | 12 ++++- .../components/providers/SettingsProvider.tsx | 23 +++++--- .../settings/membership/PortalModal.tsx | 49 ++++++++++++++--- .../membership/portal/SignupOptions.tsx | 54 ++++++++++++++++--- apps/admin-x-settings/src/hooks/useForm.tsx | 4 ++ apps/admin-x-settings/src/types/api.ts | 4 ++ apps/admin-x-settings/src/utils/api.ts | 25 ++++++++- .../admin-x-settings/src/utils/dataService.ts | 53 ++++++++++++++++++ apps/admin-x-settings/src/utils/helpers.ts | 15 +++++- apps/admin-x-settings/test/utils/e2e.ts | 21 +++++++- .../test/utils/responses/config.json | 31 +++++++++++ 13 files changed, 273 insertions(+), 33 deletions(-) create mode 100644 apps/admin-x-settings/src/utils/dataService.ts create mode 100644 apps/admin-x-settings/test/utils/responses/config.json diff --git a/apps/admin-x-settings/src/admin-x-ds/global/form/Checkbox.stories.tsx b/apps/admin-x-settings/src/admin-x-ds/global/form/Checkbox.stories.tsx index f7f3e43caa..398f91af93 100644 --- a/apps/admin-x-settings/src/admin-x-ds/global/form/Checkbox.stories.tsx +++ b/apps/admin-x-settings/src/admin-x-ds/global/form/Checkbox.stories.tsx @@ -19,8 +19,7 @@ type Story = StoryObj; export const Default: Story = { args: { - label: 'Checkbox 1', - id: 'my-radio-button' + label: 'Checkbox 1' } }; diff --git a/apps/admin-x-settings/src/admin-x-ds/global/form/Checkbox.tsx b/apps/admin-x-settings/src/admin-x-ds/global/form/Checkbox.tsx index aca50ab476..0b3488a8ef 100644 --- a/apps/admin-x-settings/src/admin-x-ds/global/form/Checkbox.tsx +++ b/apps/admin-x-settings/src/admin-x-ds/global/form/Checkbox.tsx @@ -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 = ({id, title, label, value, onChange, error, hint, checked, separator}) => { +const Checkbox: React.FC = ({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 = ({id, title, label, value, onChange, e = ({id, title, label, value, onChange, e ); }; -export default Checkbox; \ No newline at end of file +export default Checkbox; diff --git a/apps/admin-x-settings/src/components/providers/ServiceProvider.tsx b/apps/admin-x-settings/src/components/providers/ServiceProvider.tsx index 7d378b39b3..c654636a09 100644 --- a/apps/admin-x-settings/src/components/providers/ServiceProvider.tsx +++ b/apps/admin-x-settings/src/components/providers/ServiceProvider.tsx @@ -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; @@ -11,6 +13,7 @@ interface ServicesContextProps { fileService: FileService|null; officialThemes: OfficialTheme[]; search: SearchService + tiers: DataService } interface ServicesProviderProps { @@ -23,7 +26,8 @@ const ServicesContext = createContext({ api: setupGhostApi({ghostVersion: ''}), fileService: null, officialThemes: [], - search: {filter: '', setFilter: () => {}, checkVisible: () => true} + search: {filter: '', setFilter: () => {}, checkVisible: () => true}, + tiers: {data: [], update: async () => {}} }); const ServicesProvider: React.FC = ({children, ghostVersion, officialThemes}) => { @@ -35,13 +39,15 @@ const ServicesProvider: React.FC = ({children, ghostVersi } }), [apiService]); const search = useSearchService(); + const tiers = useDataService({key: 'tiers', browse: apiService.tiers.browse, edit: bulkEdit('tiers', apiService.tiers.edit)}); return ( {children} @@ -57,3 +63,5 @@ export const useApi = () => useServices().api; export const useOfficialThemes = () => useServices().officialThemes; export const useSearch = () => useServices().search; + +export const useTiers = () => useServices().tiers; diff --git a/apps/admin-x-settings/src/components/providers/SettingsProvider.tsx b/apps/admin-x-settings/src/components/providers/SettingsProvider.tsx index a569d137c7..2dad6335cb 100644 --- a/apps/admin-x-settings/src/components/providers/SettingsProvider.tsx +++ b/apps/admin-x-settings/src/components/providers/SettingsProvider.tsx @@ -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; siteData: SiteData | null; + config: Config | null; } interface SettingsProviderProps { @@ -16,6 +17,7 @@ interface SettingsProviderProps { const SettingsContext = createContext({ 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 = ({children}) => { const {api} = useContext(ServicesContext); - const [settings, setSettings] = useState (null); - const [siteData, setSiteData] = useState (null); + const [settings, setSettings] = useState (null); + const [siteData, setSiteData] = useState (null); + const [config, setConfig] = useState (null); useEffect(() => { const fetchSettings = async (): Promise => { 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 = ({children}) => { // Provide the settings and the saveSettings function to the children components return ( {children} diff --git a/apps/admin-x-settings/src/components/settings/membership/PortalModal.tsx b/apps/admin-x-settings/src/components/settings/membership/PortalModal.tsx index 3155fa2587..37d2fd26ea 100644 --- a/apps/admin-x-settings/src/components/settings/membership/PortalModal.tsx +++ b/apps/admin-x-settings/src/components/settings/membership/PortalModal.tsx @@ -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: + contents: }, { 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[], + tiers: tiers as Dirtyable[] + }, + + 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 = ; + const sidebar = ; const preview = ; let previewTabs: Tab[] = [ diff --git a/apps/admin-x-settings/src/components/settings/membership/portal/SignupOptions.tsx b/apps/admin-x-settings/src/components/settings/membership/portal/SignupOptions.tsx index 4d384efe9c..1115228e53 100644 --- a/apps/admin-x-settings/src/components/settings/membership/portal/SignupOptions.tsx +++ b/apps/admin-x-settings/src/components/settings/membership/portal/SignupOptions.tsx @@ -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 <> updateSetting('portal_name', e.target.checked)} /> -
TODO: Tiers available at signup
+ + Tiers available at signup + togglePlan('free')} /> + + {isStripeEnabled && localTiers.map(tier => ( + updateTier({...tier, visibility: checked ? 'public' : 'none'})} + /> + ))} + + {isStripeEnabled && localTiers.some(tier => tier.visibility === 'public') && ( + <> + Prices available at signup + togglePlan('monthly')} /> + togglePlan('yearly')} /> + + )} +
TODO: Display notice at signup (Koenig)
= Data & { + dirty?: boolean; +} + export type SaveState = 'unsaved' | 'saving' | 'saved' | 'error' | ''; export interface FormHook { diff --git a/apps/admin-x-settings/src/types/api.ts b/apps/admin-x-settings/src/types/api.ts index 133ada8b59..346b634610 100644 --- a/apps/admin-x-settings/src/types/api.ts +++ b/apps/admin-x-settings/src/types/api.ts @@ -5,6 +5,10 @@ export type Setting = { value: SettingValue; } +export type Config = { + [key: string]: any; +} + export type User = { id: string; name: string; diff --git a/apps/admin-x-settings/src/utils/api.ts b/apps/admin-x-settings/src/utils/api.ts index 0404ca3955..05dd095a24 100644 --- a/apps/admin-x-settings/src/utils/api.ts +++ b/apps/admin-x-settings/src/utils/api.ts @@ -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; edit: (newSettings: Setting[]) => Promise; }; + config: { + browse: () => Promise; + }; users: { browse: () => Promise; currentUser: () => Promise; @@ -161,6 +168,7 @@ export interface API { }; tiers: { browse: () => Promise + edit: (newTier: Tier) => Promise }; labels: { browse: () => Promise @@ -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: { diff --git a/apps/admin-x-settings/src/utils/dataService.ts b/apps/admin-x-settings/src/utils/dataService.ts new file mode 100644 index 0000000000..4ec7607d89 --- /dev/null +++ b/apps/admin-x-settings/src/utils/dataService.ts @@ -0,0 +1,53 @@ +import {useEffect, useState} from 'react'; + +export interface DataService { + data: Data[]; + update: (data: Data[]) => Promise; +} + +// eslint-disable-next-line no-unused-vars +type BulkEditFunction = (newData: Data[]) => Promise<{ [k in DataKey]: Data[] }> + +const useDataService = ({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 +}): DataService => { + const [data, setData] = useState([]); + + 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 = ( + key: DataKey, + // eslint-disable-next-line no-unused-vars + updateOne: (data: Data) => Promise<{ [k in DataKey]: Data[] }> +): BulkEditFunction => { + 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[] }; + }; +}; diff --git a/apps/admin-x-settings/src/utils/helpers.ts b/apps/admin-x-settings/src/utils/helpers.ts index 9eee578e6a..b58f103889 100644 --- a/apps/admin-x-settings/src/utils/helpers.ts +++ b/apps/admin-x-settings/src/utils/helpers.ts @@ -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; +} diff --git a/apps/admin-x-settings/test/utils/e2e.ts b/apps/admin-x-settings/test/utils/e2e.ts index cddea749f8..77c186c3fa 100644 --- a/apps/admin-x-settings/test/utils/e2e.ts +++ b/apps/admin-x-settings/test/utils/e2e.ts @@ -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\/\?/, diff --git a/apps/admin-x-settings/test/utils/responses/config.json b/apps/admin-x-settings/test/utils/responses/config.json new file mode 100644 index 0000000000..b876dea9be --- /dev/null +++ b/apps/admin-x-settings/test/utils/responses/config.json @@ -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" + } + } +}