0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-01-13 22:41:32 -05:00

Updated AdminX to sync data changes to Ember (#18327)

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

---

### <samp>🤖 Generated by Copilot at 7a91ba3</samp>

This pull request enables data synchronization between the Ember app and
the React app for the settings module. It passes `onUpdate` and
`onInvalidate` functions as props from the Ember app to the React app
through the `ReactApp` component and the `ServicesContext`. It also
removes unused code and adds some debugging logs in the `setting`
serializer and the `settings` service.
This commit is contained in:
Jono M 2023-09-25 17:29:09 +01:00 committed by GitHub
parent 3f04c93e21
commit 328a785065
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 137 additions and 34 deletions

View file

@ -17,10 +17,12 @@ interface AppProps {
officialThemes: OfficialTheme[];
zapierTemplates: ZapierTemplate[];
externalNavigate: (link: ExternalLink) => void;
toggleFeatureFlag: (flag: string, enabled: boolean) => void;
darkMode?: boolean;
unsplashConfig: DefaultHeaderTypes
sentryDSN: string | null;
onUpdate: (dataType: string, response: unknown) => void;
onInvalidate: (dataType: string) => void;
onDelete: (dataType: string, id: string) => void;
}
const queryClient = new QueryClient({
@ -41,7 +43,7 @@ function SentryErrorBoundary({children}: {children: React.ReactNode}) {
);
}
function App({ghostVersion, officialThemes, zapierTemplates, externalNavigate, toggleFeatureFlag, darkMode = false, unsplashConfig, sentryDSN}: AppProps) {
function App({ghostVersion, officialThemes, zapierTemplates, externalNavigate, darkMode = false, unsplashConfig, sentryDSN, onUpdate, onInvalidate, onDelete}: AppProps) {
const appClassName = clsx(
'admin-x-settings h-[100vh] w-full overflow-y-auto overflow-x-hidden',
darkMode && 'dark'
@ -50,7 +52,7 @@ function App({ghostVersion, officialThemes, zapierTemplates, externalNavigate, t
return (
<SentryErrorBoundary>
<QueryClientProvider client={queryClient}>
<ServicesProvider ghostVersion={ghostVersion} officialThemes={officialThemes} sentryDSN={sentryDSN} toggleFeatureFlag={toggleFeatureFlag} unsplashConfig={unsplashConfig} zapierTemplates={zapierTemplates}>
<ServicesProvider ghostVersion={ghostVersion} officialThemes={officialThemes} sentryDSN={sentryDSN} unsplashConfig={unsplashConfig} zapierTemplates={zapierTemplates} onDelete={onDelete} onInvalidate={onInvalidate} onUpdate={onUpdate}>
<GlobalDataProvider>
<RoutingProvider externalNavigate={externalNavigate}>
<GlobalDirtyStateProvider>

View file

@ -23,6 +23,7 @@ export const useRefreshAPIKey = createMutation<IntegrationsResponseType, {integr
path: ({integrationId, apiKeyId}) => `/integrations/${integrationId}/api_key/${apiKeyId}/refresh/`,
body: ({integrationId}) => ({integrations: [{id: integrationId}]}),
updateQueries: {
emberUpdateType: 'createOrUpdate',
dataType: integrationsDataType,
update: (newData, currentData) => (currentData && {
...(currentData as IntegrationsResponseType),

View file

@ -40,6 +40,7 @@ export const useEditCustomThemeSettings = createMutation<CustomThemeSettingsResp
body: settings => ({custom_theme_settings: settings}),
updateQueries: {
emberUpdateType: 'skip',
dataType,
update: newData => newData
}

View file

@ -16,6 +16,7 @@ export const verifyEmailToken = createMutation<EmailVerificationResponseType, em
body: ({token}) => ({token}),
updateQueries: {
dataType,
emberUpdateType: 'createOrUpdate',
update: newData => ({
...newData,
settings: newData.settings

View file

@ -41,6 +41,7 @@ export const useCreateIntegration = createMutation<IntegrationsResponseType, Par
searchParams: () => ({include: 'api_keys,webhooks'}),
updateQueries: {
dataType,
emberUpdateType: 'createOrUpdate',
update: (newData, currentData) => (currentData && {
...(currentData as IntegrationsResponseType),
integrations: (currentData as IntegrationsResponseType).integrations.concat(newData.integrations)
@ -55,6 +56,7 @@ export const useEditIntegration = createMutation<IntegrationsResponseType, Integ
searchParams: () => ({include: 'api_keys,webhooks'}),
updateQueries: {
dataType,
emberUpdateType: 'createOrUpdate',
update: (newData, currentData) => (currentData && {
...(currentData as IntegrationsResponseType),
integrations: (currentData as IntegrationsResponseType).integrations.map((integration) => {
@ -70,6 +72,7 @@ export const useDeleteIntegration = createMutation<unknown, string>({
path: id => `/integrations/${id}/`,
updateQueries: {
dataType,
emberUpdateType: 'delete',
update: (_, currentData, id) => ({
...(currentData as IntegrationsResponseType),
integrations: (currentData as IntegrationsResponseType).integrations.filter(user => user.id !== id)

View file

@ -37,6 +37,7 @@ export const useAddInvite = createMutation<InvitesResponseType, {email: string,
}),
updateQueries: {
dataType,
emberUpdateType: 'createOrUpdate',
// Assume that all invite queries should include this new one
update: (newData, currentData) => (currentData && {
...(currentData as InvitesResponseType),
@ -53,6 +54,7 @@ export const useDeleteInvite = createMutation<unknown, string>({
method: 'DELETE',
updateQueries: {
dataType,
emberUpdateType: 'delete',
update: (_, currentData, id) => ({
...(currentData as InvitesResponseType),
invites: (currentData as InvitesResponseType).invites.filter(invite => invite.id !== id)

View file

@ -77,6 +77,7 @@ export const useAddNewsletter = createMutation<NewslettersResponseType, Partial<
searchParams: payload => ({opt_in_existing: payload.opt_in_existing.toString(), include: 'count.active_members,count.posts'}),
updateQueries: {
dataType,
emberUpdateType: 'createOrUpdate',
update: insertToQueryCache('newsletters')
}
});
@ -92,6 +93,7 @@ export const useEditNewsletter = createMutation<NewslettersEditResponseType, New
defaultSearchParams: {include: 'count.active_members,count.posts'},
updateQueries: {
dataType,
emberUpdateType: 'createOrUpdate',
update: updateQueryCache('newsletters')
}
});

View file

@ -35,6 +35,7 @@ export const useEditSettings = createMutation<SettingsResponseType, Setting[]>({
body: settings => ({settings: settings.map(({key, value}) => ({key, value}))}),
updateQueries: {
dataType,
emberUpdateType: 'createOrUpdate',
update: newData => ({
...newData,
settings: newData.settings

View file

@ -54,6 +54,7 @@ export const useActivateTheme = createMutation<ThemesResponseType, string>({
path: name => `/themes/${name}/activate/`,
updateQueries: {
dataType,
emberUpdateType: 'createOrUpdate',
update: (newData: ThemesResponseType, currentData: unknown) => ({
...(currentData as ThemesResponseType),
themes: (currentData as ThemesResponseType).themes.map((theme) => {
@ -77,6 +78,7 @@ export const useDeleteTheme = createMutation<unknown, string>({
path: name => `/themes/${name}/`,
updateQueries: {
dataType,
emberUpdateType: 'delete',
update: (_, currentData, name) => ({
...(currentData as ThemesResponseType),
themes: (currentData as ThemesResponseType).themes.filter(theme => theme.name !== name)
@ -90,6 +92,7 @@ export const useInstallTheme = createMutation<ThemesInstallResponseType, string>
searchParams: repo => ({source: 'github', ref: repo}),
updateQueries: {
dataType,
emberUpdateType: 'createOrUpdate',
// Assume that all invite queries should include this new one
update: (newData, currentData) => (currentData && {
...(currentData as ThemesResponseType),
@ -111,6 +114,7 @@ export const useUploadTheme = createMutation<ThemesInstallResponseType, {file: F
},
updateQueries: {
dataType,
emberUpdateType: 'createOrUpdate',
// Assume that all invite queries should include this new one
update: (newData, currentData) => (currentData && {
...(currentData as ThemesResponseType),

View file

@ -65,6 +65,7 @@ export const useEditTier = createMutation<TiersResponseType, Tier>({
body: tier => ({tiers: [tier]}),
updateQueries: {
dataType,
emberUpdateType: 'createOrUpdate',
update: updateQueryCache('tiers')
}
});

View file

@ -99,6 +99,7 @@ export const useEditUser = createMutation<UsersResponseType, User>({
searchParams: () => ({include: 'roles'}),
updateQueries: {
dataType,
emberUpdateType: 'createOrUpdate',
update: updateQueryCache('users')
}
});
@ -108,6 +109,7 @@ export const useDeleteUser = createMutation<DeleteUserResponse, string>({
path: id => `/users/${id}/`,
updateQueries: {
dataType,
emberUpdateType: 'delete',
update: deleteFromQueryCache('users')
}
});
@ -135,6 +137,7 @@ export const useMakeOwner = createMutation<UsersResponseType, string>({
}),
updateQueries: {
dataType,
emberUpdateType: 'createOrUpdate',
update: updateQueryCache('users')
}
});

View file

@ -31,6 +31,7 @@ export const useCreateWebhook = createMutation<WebhooksResponseType, Partial<Web
body: webhook => ({webhooks: [webhook]}),
updateQueries: {
dataType: integrationsDataType,
emberUpdateType: 'createOrUpdate',
update: (newData, currentData) => (currentData && {
...(currentData as IntegrationsResponseType),
integrations: (currentData as IntegrationsResponseType).integrations.map((integration) => {
@ -52,6 +53,7 @@ export const useEditWebhook = createMutation<WebhooksResponseType, Webhook>({
body: webhook => ({webhooks: [webhook]}),
updateQueries: {
dataType: integrationsDataType,
emberUpdateType: 'createOrUpdate',
update: (newData, currentData) => (currentData && {
...(currentData as IntegrationsResponseType),
integrations: (currentData as IntegrationsResponseType).integrations.map(integration => ({
@ -69,6 +71,7 @@ export const useDeleteWebhook = createMutation<unknown, string>({
path: id => `/webhooks/${id}/`,
updateQueries: {
dataType: integrationsDataType,
emberUpdateType: 'createOrUpdate',
update: (_, currentData, id) => ({
...(currentData as IntegrationsResponseType),
integrations: (currentData as IntegrationsResponseType).integrations.map(integration => ({

View file

@ -18,8 +18,10 @@ interface ServicesContextProps {
zapierTemplates: ZapierTemplate[];
search: SearchService;
unsplashConfig: DefaultHeaderTypes;
toggleFeatureFlag: (flag: string, enabled: boolean) => void;
sentryDSN: string | null;
onUpdate: (dataType: string, response: unknown) => void;
onInvalidate: (dataType: string) => void;
onDelete: (dataType: string, id: string) => void;
}
interface ServicesProviderProps {
@ -27,9 +29,11 @@ interface ServicesProviderProps {
ghostVersion: string;
zapierTemplates: ZapierTemplate[];
officialThemes: OfficialTheme[];
toggleFeatureFlag: (flag: string, enabled: boolean) => void;
unsplashConfig: DefaultHeaderTypes;
sentryDSN: string | null;
onUpdate: (dataType: string, response: unknown) => void;
onInvalidate: (dataType: string) => void;
onDelete: (dataType: string, id: string) => void;
}
const ServicesContext = createContext<ServicesContextProps>({
@ -37,7 +41,6 @@ const ServicesContext = createContext<ServicesContextProps>({
officialThemes: [],
zapierTemplates: [],
search: {filter: '', setFilter: () => {}, checkVisible: () => true},
toggleFeatureFlag: () => {},
unsplashConfig: {
Authorization: '',
'Accept-Version': '',
@ -45,10 +48,13 @@ const ServicesContext = createContext<ServicesContextProps>({
'App-Pragma': '',
'X-Unsplash-Cache': true
},
sentryDSN: null
sentryDSN: null,
onUpdate: () => {},
onInvalidate: () => {},
onDelete: () => {}
});
const ServicesProvider: React.FC<ServicesProviderProps> = ({children, ghostVersion, zapierTemplates, officialThemes, toggleFeatureFlag, unsplashConfig, sentryDSN}) => {
const ServicesProvider: React.FC<ServicesProviderProps> = ({children, ghostVersion, zapierTemplates, officialThemes, unsplashConfig, sentryDSN, onUpdate, onInvalidate, onDelete}) => {
const search = useSearchService();
return (
@ -58,8 +64,10 @@ const ServicesProvider: React.FC<ServicesProviderProps> = ({children, ghostVersi
zapierTemplates,
search,
unsplashConfig,
toggleFeatureFlag,
sentryDSN
sentryDSN,
onUpdate,
onInvalidate,
onDelete
}}>
{children}
</ServicesContext.Provider>

View file

@ -5,14 +5,12 @@ import {ConfigResponseType, configDataType} from '../../../../api/config';
import {getSettingValue, useEditSettings} from '../../../../api/settings';
import {useGlobalData} from '../../../providers/GlobalDataProvider';
import {useQueryClient} from '@tanstack/react-query';
import {useServices} from '../../../providers/ServiceProvider';
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();
const {toggleFeatureFlag} = useServices();
return <Toggle checked={labs[flag]} onChange={async () => {
const newValue = !labs[flag];
@ -21,7 +19,6 @@ const FeatureToggle: React.FC<{ flag: string; }> = ({flag}) => {
key: 'labs',
value: JSON.stringify({...labs, [flag]: newValue})
}]);
toggleFeatureFlag(flag, newValue);
client.setQueriesData([configDataType], current => ({
config: {
...(current as ConfigResponseType).config,

View file

@ -31,9 +31,11 @@ ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
image: 'assets/img/themes/Edition.png'
}]}
sentryDSN={'' as string | null}
toggleFeatureFlag={() => {}}
unsplashConfig={{} as DefaultHeaderTypes}
zapierTemplates={[]}
onDelete={() => {}}
onInvalidate={() => {}}
onUpdate={() => {}}
/>
</React.StrictMode>
);

View file

@ -2,9 +2,9 @@ import * as Sentry from '@sentry/react';
import handleError from './handleError';
import handleResponse from './handleResponse';
import {APIError, MaintenanceError, ServerUnreachableError, TimeoutError} from '../errors';
import {QueryClient, UseInfiniteQueryOptions, UseQueryOptions, useInfiniteQuery, useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {UseInfiniteQueryOptions, UseQueryOptions, useInfiniteQuery, useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {getGhostPaths} from '../helpers';
import {useEffect, useMemo} from 'react';
import {useCallback, useEffect, useMemo} from 'react';
import {usePage, usePagination} from '../../hooks/usePagination';
import {useSentryDSN, useServices} from '../../components/providers/ServiceProvider';
@ -263,7 +263,7 @@ interface MutationOptions<ResponseData, Payload> extends Omit<QueryOptions<Respo
body?: (payload: Payload) => FormData | object;
searchParams?: (payload: Payload) => { [key: string]: string; };
invalidateQueries?: { dataType: string; };
updateQueries?: { dataType: string; update: (newData: ResponseData, currentData: unknown, payload: Payload) => unknown };
updateQueries?: { dataType: string; emberUpdateType: 'createOrUpdate' | 'delete' | 'skip'; update: (newData: ResponseData, currentData: unknown, payload: Payload) => unknown };
}
const mutate = <ResponseData, Payload>({fetchApi, path, payload, searchParams, options}: {
@ -290,22 +290,33 @@ const mutate = <ResponseData, Payload>({fetchApi, path, payload, searchParams, o
});
};
const afterMutate = <ResponseData, Payload>(newData: ResponseData, payload: Payload, queryClient: QueryClient, options: MutationOptions<ResponseData, Payload>) => {
if (options.invalidateQueries) {
queryClient.invalidateQueries([options.invalidateQueries.dataType]);
}
if (options.updateQueries) {
queryClient.setQueriesData([options.updateQueries.dataType], (data: unknown) => options.updateQueries!.update(newData, data, payload));
}
};
export const createMutation = <ResponseData, Payload>(options: MutationOptions<ResponseData, Payload>) => () => {
const fetchApi = useFetchApi();
const queryClient = useQueryClient();
const {onUpdate, onInvalidate, onDelete} = useServices();
const afterMutate = useCallback((newData: ResponseData, payload: Payload) => {
if (options.invalidateQueries) {
queryClient.invalidateQueries([options.invalidateQueries.dataType]);
onInvalidate(options.invalidateQueries.dataType);
}
if (options.updateQueries) {
queryClient.setQueriesData([options.updateQueries.dataType], (data: unknown) => options.updateQueries!.update(newData, data, payload));
if (options.updateQueries.emberUpdateType === 'createOrUpdate') {
onUpdate(options.updateQueries.dataType, newData);
} else if (options.updateQueries.emberUpdateType === 'delete') {
if (typeof payload !== 'string') {
throw new Error('Expected delete mutation to have a string (ID) payload. Either change the payload or update the createMutation hook');
}
onDelete(options.updateQueries.dataType, payload);
}
}
}, [onInvalidate, onUpdate, onDelete, queryClient]);
return useMutation<ResponseData, unknown, Payload>({
mutationFn: payload => mutate({fetchApi, path: options.path(payload), payload, searchParams: options.searchParams?.(payload) || options.defaultSearchParams, options}),
onSuccess: (newData, payload) => afterMutate(newData, payload, queryClient, options)
onSuccess: afterMutate
});
};

View file

@ -5,6 +5,7 @@ import config from 'ghost-admin/config/environment';
import ghostPaths from 'ghost-admin/utils/ghost-paths';
import {action} from '@ember/object';
import {inject} from 'ghost-admin/decorators/inject';
import {run} from '@ember/runloop';
import {inject as service} from '@ember/service';
import {tracked} from '@glimmer/tracking';
@ -256,6 +257,17 @@ const fetchSettings = function () {
return {read};
};
const emberDataTypeMapping = {
IntegrationsResponseType: {type: 'integration'},
InvitesResponseType: {type: 'invite'},
NewslettersResponseType: {type: 'newsletter'},
RecommendationsResponseType: {type: 'recommendation'},
SettingsResponseType: {type: 'setting', singleton: true},
ThemesResponseType: {type: 'theme'},
TiersResponseType: {type: 'tier'},
UsersResponseType: {type: 'user'}
};
export default class AdminXSettings extends Component {
@service ajax;
@service feature;
@ -285,12 +297,59 @@ export default class AdminXSettings extends Component {
// don't rethrow, app should attempt to gracefully recover
}
externalNavigate = ({route, models = []}) => {
this.router.transitionTo(route, ...models);
onUpdate = (dataType, response) => {
if (!emberDataTypeMapping[dataType]) {
throw new Error(`A mutation updating ${dataType} succeeded in AdminX but there is no mapping to an Ember type. Add one to emberDataTypeMapping`);
}
const {type, singleton} = emberDataTypeMapping[dataType];
if (singleton) {
// Special singleton objects like settings don't work with pushPayload, we need to add the ID explicitly
this.store.push(this.store.serializerFor(type).normalizeSingleResponse(
this.store,
this.store.modelFor(type),
response,
null,
'queryRecord'
));
} else {
this.store.pushPayload(type, response);
}
};
toggleFeatureFlag = (flag, value) => {
this.feature.set(flag, value);
onInvalidate = (dataType) => {
if (!emberDataTypeMapping[dataType]) {
throw new Error(`A mutation invalidating ${dataType} succeeded in AdminX but there is no mapping to an Ember type. Add one to emberDataTypeMapping`);
}
const {type, singleton} = emberDataTypeMapping[dataType];
if (singleton) {
// eslint-disable-next-line no-console
console.warn(`An AdminX mutation invalidated ${dataType}, but this is is marked as a singleton and cannot be reloaded in Ember. You probably wanted to use updateQueries instead of invalidateQueries`);
return;
}
run(() => this.store.unloadAll(type));
};
onDelete = (dataType, id) => {
if (!emberDataTypeMapping[dataType]) {
throw new Error(`A mutation deleting ${dataType} succeeded in AdminX but there is no mapping to an Ember type. Add one to emberDataTypeMapping`);
}
const {type} = emberDataTypeMapping[dataType];
const record = this.store.peekRecord(type, id);
if (record) {
record.unloadRecord();
}
};
externalNavigate = ({route, models = []}) => {
this.router.transitionTo(route, ...models);
};
editorResource = fetchSettings();
@ -328,10 +387,12 @@ export default class AdminXSettings extends Component {
officialThemes={officialThemes}
zapierTemplates={zapierTemplates}
externalNavigate={this.externalNavigate}
toggleFeatureFlag={this.toggleFeatureFlag}
darkMode={this.feature.nightShift}
unsplashConfig={defaultUnsplashHeaders}
sentryDSN={this.config.sentry_dsn}
onUpdate={this.onUpdate}
onInvalidate={this.onInvalidate}
onDelete={this.onDelete}
/>
</Suspense>
</ErrorHandler>

View file

@ -23,7 +23,7 @@ export default class Setting extends ApplicationSerializer {
}
serializeAttribute(snapshot, json, key, attributes) {
// Only serialize attributes that have changed and
// Only serialize attributes that have changed and
// send a partial update to the API to avoid conflicts
// with different screens using the same model
// See https://github.com/TryGhost/Ghost/issues/15470