Fix CMS app issues (#386)

* Fix CMS app visual issues

* Add provider configuration instance ping status

* Skip update webhooks processing if issuing principal is this CMS app

* Fix provider configuration form validation

* Create old-dingos-hide.md
This commit is contained in:
Dawid 2023-04-18 18:46:28 +02:00 committed by GitHub
parent 2c0df91351
commit a3636f73ef
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 4658 additions and 4606 deletions

View file

@ -0,0 +1,6 @@
---
"saleor-app-cms": patch
---
Fix CMS app issues
Check if CMS provider instance configuration is working

View file

@ -62,7 +62,7 @@
"eslint-config-prettier": "^8.5.0", "eslint-config-prettier": "^8.5.0",
"jsdom": "^20.0.3", "jsdom": "^20.0.3",
"prettier": "^2.7.1", "prettier": "^2.7.1",
"typescript": "4.9", "typescript": "5.0.4",
"vitest": "^0.30.1" "vitest": "^0.30.1"
} }
} }

View file

@ -2,9 +2,47 @@ import {
ProductVariantUpdatedWebhookPayloadFragment, ProductVariantUpdatedWebhookPayloadFragment,
WebhookProductVariantFragment, WebhookProductVariantFragment,
} from "../../../../generated/graphql"; } from "../../../../generated/graphql";
import { CmsClientBatchOperations, CmsClientOperations, ProductResponseSuccess } from "../types"; import {
BaseResponse,
CmsClientBatchOperations,
CmsClientOperations,
ProductResponseSuccess,
} from "../types";
import { getCmsIdFromSaleorItem } from "./metadata"; import { getCmsIdFromSaleorItem } from "./metadata";
import { logger as pinoLogger } from "../../logger"; import { logger as pinoLogger } from "../../logger";
import { CMSProvider, cmsProviders } from "../providers";
import { ProviderInstanceSchema, providersSchemaSet } from "../config";
export const pingProviderInstance = async (
providerInstanceSettings: ProviderInstanceSchema
): Promise<BaseResponse> => {
const logger = pinoLogger.child({ providerInstanceSettings });
logger.debug("Ping provider instance called");
const provider = cmsProviders[
providerInstanceSettings.providerName as CMSProvider
] as (typeof cmsProviders)[keyof typeof cmsProviders];
const validation =
providersSchemaSet[providerInstanceSettings.providerName as CMSProvider].safeParse(
providerInstanceSettings
);
if (!validation.success) {
logger.error("The provider instance settings validation failed.", {
error: validation.error.message,
});
return { ok: false };
}
const config = validation.data;
const client = provider.create(config as any); // config without validation = providerInstanceSettings as any
const pingResult = await client.ping();
return pingResult;
};
interface CmsClientOperationResult { interface CmsClientOperationResult {
createdCmsId?: string; createdCmsId?: string;

View file

@ -31,7 +31,7 @@ export const providersConfig = {
name: "token", name: "token",
label: "Token", label: "Token",
helpText: helpText:
'You can find this in your Contentful project, go to Settings > API keys > Content management tokens > Generate personal token. More instructions at [Contentful "Authentication" documentation](https://www.contentful.com/developers/docs/references/authentication/).', 'You can find this in your Contentful project, go to Settings > API Keys > Content Management Tokens > Generate Personal Token. More instructions at [Contentful "Authentication" documentation](https://www.contentful.com/developers/docs/references/authentication/).',
}, },
{ {
required: true, required: true,
@ -45,14 +45,14 @@ export const providersConfig = {
name: "spaceId", name: "spaceId",
label: "Space ID", label: "Space ID",
helpText: helpText:
"You can find this in your Contentful project, go to settings > general settings.", "You can find this in your Contentful project, go to Settings > General Settings.",
}, },
{ {
required: true, required: true,
name: "contentId", name: "contentId",
label: "Content ID", label: "Content ID",
helpText: helpText:
"You can find this in your Contentful project, go to Content model > select model > Content type id.", "You can find this in your Contentful project, go to Content Model > select Model > Content Type ID.",
}, },
{ {
required: true, required: true,
@ -71,7 +71,7 @@ export const providersConfig = {
name: "apiRequestsPerSecond", name: "apiRequestsPerSecond",
label: "API requests per second", label: "API requests per second",
helpText: helpText:
"API rate limits. Default 7. Used in bulk products variants sync. Higher rate limits may speed up a little products variants bulk sync. Higher rate limit may apply depending on different Contentful plan, learn more at https://www.contentful.com/developers/docs/references/content-management-api/#/introduction/api-rate-limits.", "API rate limits. The default is 7. Used in bulk products variants sync. Higher rate limits may speed up a little products variants bulk sync. Higher rate limit may apply depending on different Contentful plan, learn more at https://www.contentful.com/developers/docs/references/content-management-api/#/introduction/api-rate-limits.",
}, },
], ],
}, },
@ -99,7 +99,7 @@ export const providersConfig = {
name: "contentTypeId", name: "contentTypeId",
label: "Content Type ID (plural)", label: "Content Type ID (plural)",
helpText: helpText:
'You can find this in your Strapi project, go to Content-Type Builder > select content type > click Edit > use API ID (Plural). More instructions at [Strapi "Editing content types" documentation](https://docs.strapi.io/user-docs/content-type-builder/managing-content-types#editing-content-types).', 'You can find this in your Strapi project, go to Content-Type Builder > select Content Type > click Edit > Use API ID (Plural). More instructions at [Strapi "Editing content types" documentation](https://docs.strapi.io/user-docs/content-type-builder/managing-content-types#editing-content-types).',
}, },
], ],
}, },
@ -146,7 +146,7 @@ export type DatocmsConfig = CreateProviderConfig<"datocms">;
export const strapiConfigSchema = z.object({ export const strapiConfigSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
token: z.string().min(1), token: z.string().min(1),
baseUrl: z.string().min(1), baseUrl: z.string().url().min(1),
contentTypeId: z.string().min(1), contentTypeId: z.string().min(1),
}); });
@ -157,15 +157,15 @@ export const contentfulConfigSchema = z.object({
spaceId: z.string().min(1), spaceId: z.string().min(1),
locale: z.string().min(1), locale: z.string().min(1),
contentId: z.string().min(1), contentId: z.string().min(1),
baseUrl: z.string(), baseUrl: z.string().url().optional().or(z.literal("")),
apiRequestsPerSecond: z.string(), apiRequestsPerSecond: z.number().optional().or(z.literal("")),
}); });
export const datocmsConfigSchema = z.object({ export const datocmsConfigSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
token: z.string().min(1), token: z.string().min(1),
itemTypeId: z.string().min(1), itemTypeId: z.number().min(1),
baseUrl: z.string(), baseUrl: z.string().url().optional().or(z.literal("")),
environment: z.string(), environment: z.string(),
}); });

View file

@ -101,6 +101,15 @@ const contentfulOperations: CreateOperations<ContentfulConfig> = (config) => {
const requestPerSecondLimit = Number(apiRequestsPerSecond || 7); const requestPerSecondLimit = Number(apiRequestsPerSecond || 7);
const pingCMS = async () => {
const endpoint = `/spaces/${spaceId}`;
const response = await contentfulFetch(endpoint, config, { method: "GET" });
logger.debug({ response }, "pingCMS response");
return {
ok: response.ok,
};
};
const createProductInCMS = async (input: ProductInput): Promise<ContentfulResponse> => { const createProductInCMS = async (input: ProductInput): Promise<ContentfulResponse> => {
// Contentful API does not auto generate resource ID during creation, it has to be provided. // Contentful API does not auto generate resource ID during creation, it has to be provided.
const resourceId = uuidv4(); const resourceId = uuidv4();
@ -203,6 +212,12 @@ const contentfulOperations: CreateOperations<ContentfulConfig> = (config) => {
}; };
return { return {
ping: async () => {
const response = await pingCMS();
logger.debug({ response }, "ping response");
return response;
},
createProduct: async ({ input }) => { createProduct: async ({ input }) => {
const result = await createProductInCMS(input); const result = await createProductInCMS(input);
logger.debug({ result }, "createProduct result"); logger.debug({ result }, "createProduct result");

View file

@ -50,10 +50,12 @@ const datocmsOperations: CreateOperations<DatocmsConfig> = (config) => {
const client = datocmsClient(config); const client = datocmsClient(config);
const pingCMS = async () => client.users.findMe();
const createProductInCMS = async (input: ProductInput) => const createProductInCMS = async (input: ProductInput) =>
client.items.create({ client.items.create({
item_type: { item_type: {
id: config.itemTypeId, id: String(config.itemTypeId),
type: "item_type", type: "item_type",
}, },
saleor_id: input.saleorId, saleor_id: input.saleorId,
@ -91,6 +93,20 @@ const datocmsOperations: CreateOperations<DatocmsConfig> = (config) => {
}); });
return { return {
ping: async () => {
try {
const response = await pingCMS();
logger.debug({ response }, "ping response");
if (!response.id) {
throw new Error();
}
return { ok: true };
} catch (error) {
return { ok: false };
}
},
createProduct: async ({ input }) => { createProduct: async ({ input }) => {
try { try {
const item = await createProductInCMS(input); const item = await createProductInCMS(input);

View file

@ -82,6 +82,14 @@ export const strapiOperations: CreateStrapiOperations = (config) => {
const { contentTypeId } = config; const { contentTypeId } = config;
const pingCMS = async () => {
const response = await strapiFetch(`/${contentTypeId}`, config, {
method: "GET",
});
logger.debug({ response }, "pingCMS response");
return { ok: response.ok };
};
const createProductInCMS = async (input: ProductInput): Promise<StrapiResponse> => { const createProductInCMS = async (input: ProductInput): Promise<StrapiResponse> => {
const body = transformInputToBody(input); const body = transformInputToBody(input);
const response = await strapiFetch(`/${contentTypeId}`, config, { const response = await strapiFetch(`/${contentTypeId}`, config, {
@ -120,6 +128,12 @@ export const strapiOperations: CreateStrapiOperations = (config) => {
}; };
return { return {
ping: async () => {
const response = await pingCMS();
logger.debug({ response }, "ping response");
return response;
},
createProduct: async ({ input }) => { createProduct: async ({ input }) => {
const result = await createProductInCMS(input); const result = await createProductInCMS(input);
logger.debug({ result }, "createProduct result"); logger.debug({ result }, "createProduct result");

View file

@ -1,5 +1,5 @@
import { z } from "zod"; import { z } from "zod";
import { providersConfig } from "./config"; import { ProvidersSchema, providersConfig } from "./config";
export type ProductInput = Record<string, any> & { export type ProductInput = Record<string, any> & {
saleorId: string; saleorId: string;
@ -11,11 +11,13 @@ export type ProductInput = Record<string, any> & {
image?: string; image?: string;
}; };
export type BaseResponse = { ok: boolean };
export type ProductResponseSuccess = { ok: true; data: { id: string; saleorId: string } }; export type ProductResponseSuccess = { ok: true; data: { id: string; saleorId: string } };
export type ProductResponseError = { ok: false; error: string }; export type ProductResponseError = { ok: false; error: string };
export type ProductResponse = ProductResponseSuccess | ProductResponseError; export type ProductResponse = ProductResponseSuccess | ProductResponseError;
export type CmsOperations = { export type CmsOperations = {
ping: () => Promise<BaseResponse>;
getProduct?: ({ id }: { id: string }) => Promise<Response>; getProduct?: ({ id }: { id: string }) => Promise<Response>;
createProduct: ({ input }: { input: ProductInput }) => Promise<ProductResponse>; createProduct: ({ input }: { input: ProductInput }) => Promise<ProductResponse>;
updateProduct: ({ id, input }: { id: string; input: ProductInput }) => Promise<Response | void>; updateProduct: ({ id, input }: { id: string; input: ProductInput }) => Promise<Response | void>;
@ -40,17 +42,14 @@ export type CmsClientBatchOperations = {
operationType: keyof CmsBatchOperations; operationType: keyof CmsBatchOperations;
}; };
export type GetProviderTokens<TProviderName extends keyof typeof providersConfig> =
(typeof providersConfig)[TProviderName]["tokens"][number];
export type BaseConfig = { export type BaseConfig = {
name: string; name: string;
}; };
// * Generates the config based on the data supplied in the `providersConfig` variable. // * Generates the config based on the data supplied in the `providersConfig` variable.
export type CreateProviderConfig<TProviderName extends keyof typeof providersConfig> = Record< export type CreateProviderConfig<TProviderName extends keyof typeof providersConfig> = Omit<
GetProviderTokens<TProviderName>["name"], ProvidersSchema[TProviderName],
string "id" | "providerName"
> & > &
BaseConfig; BaseConfig;

View file

@ -41,11 +41,15 @@ export const Channels = () => {
} }
}, [channels]); }, [channels]);
const handleOnSyncCompleted = (providerInstanceId: string) => { const handleOnSyncCompleted = (providerInstanceId: string, error?: string) => {
if (!activeChannel) { if (!activeChannel) {
return; return;
} }
if (error) {
return;
}
saveChannel({ saveChannel({
...activeChannel, ...activeChannel,
requireSyncProviderInstances: activeChannel.requireSyncProviderInstances?.filter( requireSyncProviderInstances: activeChannel.requireSyncProviderInstances?.filter(

View file

@ -1,4 +1,4 @@
import { useAppBridge } from "@saleor/app-sdk/app-bridge"; import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
import { SALEOR_API_URL_HEADER, SALEOR_AUTHORIZATION_BEARER_HEADER } from "@saleor/app-sdk/const"; import { SALEOR_API_URL_HEADER, SALEOR_AUTHORIZATION_BEARER_HEADER } from "@saleor/app-sdk/const";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { WebhookProductVariantFragment } from "../../../../generated/graphql"; import { WebhookProductVariantFragment } from "../../../../generated/graphql";
@ -21,9 +21,9 @@ interface UseProductsVariantsSyncHandlers {
export const useProductsVariantsSync = ( export const useProductsVariantsSync = (
channelSlug: string | null, channelSlug: string | null,
onSyncCompleted: (providerInstanceId: string) => void onSyncCompleted: (providerInstanceId: string, error?: string) => void
): UseProductsVariantsSyncHandlers => { ): UseProductsVariantsSyncHandlers => {
const { appBridgeState } = useAppBridge(); const { appBridge, appBridgeState } = useAppBridge();
const [startedProviderInstanceId, setStartedProviderInstanceId] = useState<string>(); const [startedProviderInstanceId, setStartedProviderInstanceId] = useState<string>();
const [startedOperation, setStartedOperation] = useState<ProductsVariantsSyncOperation>(); const [startedOperation, setStartedOperation] = useState<ProductsVariantsSyncOperation>();
@ -78,6 +78,33 @@ export const useProductsVariantsSync = (
} }
}; };
const completeSync = (completedProviderInstanceIdSync: string, error?: string) => {
setStartedProviderInstanceId(undefined);
setStartedOperation(undefined);
setCurrentProductIndex(0);
onSyncCompleted(completedProviderInstanceIdSync, error);
if (error) {
appBridge?.dispatch(
actions.Notification({
title: "Error",
status: "error",
text: "Error syncing products variants",
apiMessage: error,
})
);
} else {
appBridge?.dispatch(
actions.Notification({
title: "Success",
status: "success",
text: "Products variants sync completed successfully",
})
);
}
};
useEffect(() => { useEffect(() => {
if ( if (
products.length <= currentProductIndex && products.length <= currentProductIndex &&
@ -85,13 +112,7 @@ export const useProductsVariantsSync = (
startedProviderInstanceId && startedProviderInstanceId &&
startedOperation startedOperation
) { ) {
const completedProviderInstanceIdSync = startedProviderInstanceId; completeSync(startedProviderInstanceId);
setStartedProviderInstanceId(undefined);
setStartedOperation(undefined);
setCurrentProductIndex(0);
onSyncCompleted(completedProviderInstanceIdSync);
} }
}, [products.length, currentProductIndex, fetchCompleted]); }, [products.length, currentProductIndex, fetchCompleted]);
@ -112,10 +133,19 @@ export const useProductsVariantsSync = (
const productsBatch = products.slice(productsBatchStartIndex, productsBatchEndIndex); const productsBatch = products.slice(productsBatchStartIndex, productsBatchEndIndex);
// temporary solution, cannot use directly backend methods without fetch, due to non-browser Node dependency, like await cmsProvider.updatedBatchProducts(productsBatch); // temporary solution, cannot use directly backend methods without fetch, due to non-browser Node dependency, like await cmsProvider.updatedBatchProducts(productsBatch);
await syncFetch(startedProviderInstanceId, startedOperation, productsBatch); const syncResult = await syncFetch(
startedProviderInstanceId,
startedOperation,
productsBatch
);
if (syncResult.error) {
completeSync(startedProviderInstanceId, syncResult.error);
} else {
setCurrentProductIndex(productsBatchEndIndex);
}
setIsImporting(false); setIsImporting(false);
setCurrentProductIndex(productsBatchEndIndex);
})(); })();
}, [ }, [
startedProviderInstanceId, startedProviderInstanceId,

View file

@ -0,0 +1,70 @@
import { useEffect, useState } from "react";
import { SALEOR_API_URL_HEADER, SALEOR_AUTHORIZATION_BEARER_HEADER } from "@saleor/app-sdk/const";
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
const getCurrentTime = () => new Date().toLocaleTimeString();
export interface ProviderInstancePingStatus {
providerInstanceId: string;
success: boolean;
time: string;
}
export interface PingProviderInstanceOpts {
result: ProviderInstancePingStatus | null;
refresh: () => void;
}
export const usePingProviderInstance = (providerInstanceId: string | null) => {
const { appBridgeState } = useAppBridge();
const [result, setResult] = useState<null | ProviderInstancePingStatus>(null);
const ping = async (providerInstanceId: string): Promise<ProviderInstancePingStatus> => {
try {
const pingResponse = await fetch("/api/ping-provider-instance", {
method: "POST",
headers: [
["content-type", "application/json"],
[SALEOR_API_URL_HEADER, appBridgeState?.saleorApiUrl!],
[SALEOR_AUTHORIZATION_BEARER_HEADER, appBridgeState?.token!],
],
body: JSON.stringify({
providerInstanceId,
}),
});
const pingResult = await pingResponse.json();
return {
providerInstanceId,
success: pingResult.success,
time: getCurrentTime(),
};
} catch (error) {
console.error("useProductsVariantsSync syncFetch error", error);
return {
providerInstanceId,
success: false,
time: getCurrentTime(),
};
}
};
const refresh = () => {
setResult(null);
if (providerInstanceId) {
ping(providerInstanceId).then((result) => setResult(result));
}
};
useEffect(() => {
refresh();
}, [providerInstanceId]);
return {
result,
refresh,
};
};

View file

@ -12,6 +12,7 @@ import {
} from "../../../lib/cms/config"; } from "../../../lib/cms/config";
import { Provider } from "../../providers/config"; import { Provider } from "../../providers/config";
import { AppMarkdownText } from "../../ui/app-markdown-text"; import { AppMarkdownText } from "../../ui/app-markdown-text";
import { ZodNumber } from "zod";
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
footer: { footer: {
@ -68,8 +69,6 @@ export const ProviderInstanceConfigurationForm = <TProvider extends CMSProviderS
}, [provider, providerInstance]); }, [provider, providerInstance]);
const submitHandler = (values: SingleProviderSchema) => { const submitHandler = (values: SingleProviderSchema) => {
console.log(values);
onSubmit(values); onSubmit(values);
}; };
@ -111,35 +110,40 @@ export const ProviderInstanceConfigurationForm = <TProvider extends CMSProviderS
} }
/> />
</Grid> </Grid>
{fields.map((token) => ( {fields.map((token) => {
<Grid xs={12} item key={token.name}> const isSecret = token.secret ? { type: "password" } : {};
<TextField
{...register(token.name as Path<ProvidersSchema[TProvider]>, { return (
required: "required" in token && token.required, <Grid xs={12} item key={token.name}>
})} <TextField
// required={"required" in token && token.required} {...register(token.name as Path<ProvidersSchema[TProvider]>, {
label={token.label} required: "required" in token && token.required,
type={token.secret ? "password" : "text"} valueAsNumber:
name={token.name} schema.shape[token.name as keyof typeof schema.shape] instanceof ZodNumber,
InputLabelProps={{ })}
shrink: !!watch(token.name as Path<ProvidersSchema[TProvider]>), {...isSecret}
}} label={token.label}
fullWidth name={token.name}
// @ts-ignore TODO: fix errors typing InputLabelProps={{
error={!!errors[token.name as Path<ProvidersSchema[TProvider]>]} shrink: !!watch(token.name as Path<ProvidersSchema[TProvider]>),
helperText={ }}
<> fullWidth
{errors[token.name as Path<ProvidersSchema[TProvider]>]?.message || // @ts-ignore TODO: fix errors typing
("helpText" in token && ( error={!!errors[token.name as Path<ProvidersSchema[TProvider]>]}
<AppMarkdownText>{`${getOptionalText(token)}${ helperText={
token.helpText <>
}`}</AppMarkdownText> {errors[token.name as Path<ProvidersSchema[TProvider]>]?.message ||
))} ("helpText" in token && (
</> <AppMarkdownText>{`${getOptionalText(token)}${
} token.helpText
/> }`}</AppMarkdownText>
</Grid> ))}
))} </>
}
/>
</Grid>
);
})}
{providerInstance ? ( {providerInstance ? (
<Grid item xs={12}> <Grid item xs={12}>
<TextField <TextField

View file

@ -8,6 +8,8 @@ import { ProviderInstanceConfigurationForm } from "./provider-instance-configura
import { Skeleton } from "@material-ui/lab"; import { Skeleton } from "@material-ui/lab";
import { ProvidersErrors, ProvidersLoading } from "./types"; import { ProvidersErrors, ProvidersLoading } from "./types";
import { getProviderByName, Provider } from "../../providers/config"; import { getProviderByName, Provider } from "../../providers/config";
import { ProviderInstancePingStatus } from "./hooks/usePingProviderInstance";
import { ProviderInstancePing } from "./provider-instance-ping";
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
radioLabel: { radioLabel: {
@ -51,6 +53,12 @@ const useStyles = makeStyles((theme) => ({
borderRadius: 8, borderRadius: 8,
padding: 20, padding: 20,
}, },
successStatus: {
color: theme.palette.type === "dark" ? theme.palette.success.light : theme.palette.success.dark,
},
errorStatus: {
color: theme.palette.error.main,
},
})); }));
const ProviderInstanceConfigurationSkeleton = () => { const ProviderInstanceConfigurationSkeleton = () => {
@ -94,6 +102,7 @@ interface ProviderInstanceConfigurationProps {
deleteProviderInstance: (providerInstance: SingleProviderSchema) => any; deleteProviderInstance: (providerInstance: SingleProviderSchema) => any;
loading: ProvidersLoading; loading: ProvidersLoading;
errors: ProvidersErrors; errors: ProvidersErrors;
providerInstancePingStatus: ProviderInstancePingStatus | null;
onNewProviderRequest(): void; onNewProviderRequest(): void;
} }
@ -103,6 +112,7 @@ export const ProviderInstanceConfiguration = ({
saveProviderInstance, saveProviderInstance,
deleteProviderInstance, deleteProviderInstance,
loading, loading,
providerInstancePingStatus,
onNewProviderRequest, onNewProviderRequest,
errors, errors,
}: ProviderInstanceConfigurationProps) => { }: ProviderInstanceConfigurationProps) => {
@ -124,6 +134,9 @@ export const ProviderInstanceConfiguration = ({
setSelectedProvider(provider); setSelectedProvider(provider);
}; };
const isPingStatusLoading =
providerInstancePingStatus?.providerInstanceId !== activeProviderInstance?.id;
if (loading.fetching || loading.saving) { if (loading.fetching || loading.saving) {
return <ProviderInstanceConfigurationSkeleton />; return <ProviderInstanceConfigurationSkeleton />;
} }
@ -196,6 +209,15 @@ export const ProviderInstanceConfiguration = ({
</RadioGroup> </RadioGroup>
{selectedProvider ? ( {selectedProvider ? (
<> <>
{!newProviderInstance && (
<>
<br />
<ProviderInstancePing
loading={isPingStatusLoading}
status={providerInstancePingStatus}
/>
</>
)}
<br /> <br />
<ProviderInstanceConfigurationForm <ProviderInstanceConfigurationForm
provider={selectedProvider} provider={selectedProvider}

View file

@ -0,0 +1,46 @@
import { Typography } from "@material-ui/core";
import { Skeleton } from "@material-ui/lab";
import { makeStyles } from "@saleor/macaw-ui";
import { ProviderInstancePingStatus } from "./hooks/usePingProviderInstance";
import clsx from "clsx";
const useStyles = makeStyles((theme) => ({
successStatus: {
color: theme.palette.type === "dark" ? theme.palette.success.light : theme.palette.success.dark,
},
errorStatus: {
color: theme.palette.error.main,
},
}));
interface ProviderInstancePingStatusProps {
loading: boolean;
status: ProviderInstancePingStatus | null;
}
export const ProviderInstancePing = ({ loading, status }: ProviderInstancePingStatusProps) => {
const styles = useStyles();
const parseProviderInstanceStatus = () => {
const statusText = status?.success ? "Ok" : "Error";
const checkTime = `(check time ${status?.time})`;
return `Configuration connection: ${statusText} ${checkTime}`;
};
if (loading) {
return <Skeleton />;
}
return (
<Typography
variant="body1"
className={clsx({
[styles.successStatus]: status?.success,
[styles.errorStatus]: !status?.success,
})}
>
{parseProviderInstanceStatus()}
</Typography>
);
};

View file

@ -8,6 +8,7 @@ import { Button, makeStyles } from "@saleor/macaw-ui";
import { ProviderInstancesSelect } from "./provider-instances-list"; import { ProviderInstancesSelect } from "./provider-instances-list";
import { Add } from "@material-ui/icons"; import { Add } from "@material-ui/icons";
import { useDashboardNotification } from "@saleor/apps-shared"; import { useDashboardNotification } from "@saleor/apps-shared";
import { usePingProviderInstance } from "./hooks/usePingProviderInstance";
const useStyles = makeStyles({ const useStyles = makeStyles({
wrapper: { wrapper: {
@ -26,6 +27,7 @@ export const ProviderInstances = () => {
const [newProviderInstance, setNewProviderInstance] = useState<SingleProviderSchema | null>(null); const [newProviderInstance, setNewProviderInstance] = useState<SingleProviderSchema | null>(null);
const { notifySuccess } = useDashboardNotification(); const { notifySuccess } = useDashboardNotification();
const pingProviderInstanceOpts = usePingProviderInstance(activeProviderInstanceId);
useEffect(() => { useEffect(() => {
if (providerInstances.length && !activeProviderInstanceId) { if (providerInstances.length && !activeProviderInstanceId) {
@ -58,6 +60,9 @@ export const ProviderInstances = () => {
if (newProviderInstance && savedProviderInstance) { if (newProviderInstance && savedProviderInstance) {
setActiveProviderInstanceId(savedProviderInstance.id); setActiveProviderInstanceId(savedProviderInstance.id);
} }
if (!newProviderInstance) {
pingProviderInstanceOpts.refresh();
}
}; };
const handleDeleteProviderInstance = async (providerInstance: SingleProviderSchema) => { const handleDeleteProviderInstance = async (providerInstance: SingleProviderSchema) => {
await deleteProviderInstance(providerInstance); await deleteProviderInstance(providerInstance);
@ -94,6 +99,7 @@ export const ProviderInstances = () => {
deleteProviderInstance={handleDeleteProviderInstance} deleteProviderInstance={handleDeleteProviderInstance}
loading={loading} loading={loading}
errors={errors} errors={errors}
providerInstancePingStatus={pingProviderInstanceOpts.result}
onNewProviderRequest={handleAddNewProviderInstance} onNewProviderRequest={handleAddNewProviderInstance}
/> />
</div> </div>

View file

@ -0,0 +1,70 @@
import { NextProtectedApiHandler, createProtectedHandler } from "@saleor/app-sdk/handlers/next";
import { saleorApp } from "../../../saleor-app";
import type { NextApiRequest, NextApiResponse } from "next";
import { logger as pinoLogger } from "../../lib/logger";
import { createClient } from "../../lib/graphql";
import { createSettingsManager } from "../../lib/metadata";
import { getProviderInstancesSettings } from "../../lib/cms/client/settings";
import { pingProviderInstance } from "../../lib/cms/client/clients-execution";
export interface ProviderInstancePingApiPayload {
providerInstanceId: string;
}
export interface ProviderInstancePingApiResponse {
success: boolean;
}
const handler: NextProtectedApiHandler = async (
req: NextApiRequest,
res: NextApiResponse<ProviderInstancePingApiResponse>,
context
) => {
const { authData } = context;
const { providerInstanceId } = req.body as ProviderInstancePingApiPayload;
const logger = pinoLogger.child({
endpoint: "ping-provider-instance",
});
logger.debug({ providerInstanceId }, "Called endpoint ping-provider-instance");
if (req.method !== "POST") {
return res.status(405).json({
success: false,
});
}
if (!providerInstanceId) {
return res.status(400).json({
success: false,
});
}
const client = createClient(authData.saleorApiUrl, async () => ({
token: authData.token,
}));
const settingsManager = createSettingsManager(client);
const providerInstancesSettingsParsed = await getProviderInstancesSettings(settingsManager);
const providerInstanceSettings = providerInstancesSettingsParsed[providerInstanceId];
if (!providerInstanceSettings) {
return res.status(400).json({
success: false,
});
}
const pingResult = await pingProviderInstance(providerInstanceSettings);
if (!pingResult.ok) {
return res.status(400).json({
success: false,
});
}
return res.status(200).json({
success: true,
});
};
export default createProtectedHandler(handler, saleorApp.apl, ["MANAGE_APPS"]);

View file

@ -156,13 +156,25 @@ const handler: NextProtectedApiHandler = async (
})) || [], })) || [],
}); });
if (syncResult?.error) {
logger.error({ error: syncResult.error }, "The sync result error.");
return res.status(500).json({
success: false,
data: {
createdCMSIds: syncResult?.createdCmsIds || [],
deletedCMSIds: syncResult?.deletedCmsIds || [],
},
error: syncResult?.error,
});
}
logger.debug("The sync result success.");
return res.status(200).json({ return res.status(200).json({
success: true, success: true,
data: { data: {
createdCMSIds: syncResult?.createdCmsIds || [], createdCMSIds: syncResult?.createdCmsIds || [],
deletedCMSIds: syncResult?.deletedCmsIds || [], deletedCMSIds: syncResult?.deletedCmsIds || [],
}, },
error: syncResult?.error,
}); });
}; };

View file

@ -0,0 +1,12 @@
interface UnknownIssuingPrincipal {
__typename?: string;
}
export const isAppWebhookIssuer = <T extends UnknownIssuingPrincipal | null | undefined>(
issuingPrincipal: T,
appId: string
) =>
issuingPrincipal &&
(!issuingPrincipal.__typename || issuingPrincipal.__typename === "App") &&
"id" in issuingPrincipal &&
issuingPrincipal.id === appId;

View file

@ -11,6 +11,7 @@ import { createCmsOperations, executeCmsOperations, updateMetadata } from "../..
import { logger as pinoLogger } from "../../../lib/logger"; import { logger as pinoLogger } from "../../../lib/logger";
import { createClient } from "../../../lib/graphql"; import { createClient } from "../../../lib/graphql";
import { fetchProductVariantMetadata } from "../../../lib/metadata"; import { fetchProductVariantMetadata } from "../../../lib/metadata";
import { isAppWebhookIssuer } from "./_utils";
export const config = { export const config = {
api: { api: {
@ -21,6 +22,11 @@ export const config = {
export const ProductUpdatedWebhookPayload = gql` export const ProductUpdatedWebhookPayload = gql`
${UntypedWebhookProductFragmentDoc} ${UntypedWebhookProductFragmentDoc}
fragment ProductUpdatedWebhookPayload on ProductUpdated { fragment ProductUpdatedWebhookPayload on ProductUpdated {
issuingPrincipal {
... on App {
id
}
}
product { product {
...WebhookProduct ...WebhookProduct
} }
@ -49,13 +55,19 @@ export const handler: NextWebhookApiHandler<ProductUpdatedWebhookPayloadFragment
res, res,
context context
) => { ) => {
const { product } = context.payload; const { product, issuingPrincipal } = context.payload;
const { saleorApiUrl, token } = context.authData; const { saleorApiUrl, token, appId } = context.authData;
const logger = pinoLogger.child({ const logger = pinoLogger.child({
product, product,
}); });
logger.debug("Called webhook PRODUCT_UPDATED"); logger.debug("Called webhook PRODUCT_UPDATED");
logger.debug({ issuingPrincipal }, "Issuing principal");
if (isAppWebhookIssuer(issuingPrincipal, appId)) {
logger.debug("Issuing principal is the same as the app. Skipping webhook processing.");
return res.status(200).end();
}
if (!product) { if (!product) {
return res.status(500).json({ return res.status(500).json({

View file

@ -11,6 +11,7 @@ import { createCmsOperations, executeCmsOperations, updateMetadata } from "../..
import { logger as pinoLogger } from "../../../lib/logger"; import { logger as pinoLogger } from "../../../lib/logger";
import { createClient } from "../../../lib/graphql"; import { createClient } from "../../../lib/graphql";
import { fetchProductVariantMetadata } from "../../../lib/metadata"; import { fetchProductVariantMetadata } from "../../../lib/metadata";
import { isAppWebhookIssuer } from "./_utils";
export const config = { export const config = {
api: { api: {
@ -21,6 +22,11 @@ export const config = {
export const ProductVariantUpdatedWebhookPayload = gql` export const ProductVariantUpdatedWebhookPayload = gql`
${UntypedWebhookProductVariantFragmentDoc} ${UntypedWebhookProductVariantFragmentDoc}
fragment ProductVariantUpdatedWebhookPayload on ProductVariantUpdated { fragment ProductVariantUpdatedWebhookPayload on ProductVariantUpdated {
issuingPrincipal {
... on App {
id
}
}
productVariant { productVariant {
...WebhookProductVariant ...WebhookProductVariant
} }
@ -50,13 +56,19 @@ export const handler: NextWebhookApiHandler<ProductVariantUpdatedWebhookPayloadF
res, res,
context context
) => { ) => {
const { productVariant } = context.payload; const { productVariant, issuingPrincipal } = context.payload;
const { saleorApiUrl, token } = context.authData; const { saleorApiUrl, token, appId } = context.authData;
const logger = pinoLogger.child({ const logger = pinoLogger.child({
productVariant, productVariant,
}); });
logger.debug("Called webhook PRODUCT_VARIANT_UPDATED"); logger.debug("Called webhook PRODUCT_VARIANT_UPDATED");
logger.debug({ issuingPrincipal }, "Issuing principal");
if (isAppWebhookIssuer(issuingPrincipal, appId)) {
logger.debug("Issuing principal is the same as the app. Skipping webhook processing.");
return res.status(200).end();
}
if (!productVariant) { if (!productVariant) {
return res.status(500).json({ return res.status(500).json({

File diff suppressed because it is too large Load diff