CMS UI and webhook updates (#319)

* CMS UI updates

* Fix/improve verification against existing product variant in cms providers

* Mock urql client in tests

* Mark optional text with asterisk
This commit is contained in:
Dawid 2023-03-22 18:18:57 +01:00 committed by GitHub
parent 8f2d5e8960
commit 84df6acf9c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 137 additions and 37 deletions

View file

@ -0,0 +1,9 @@
query FetchProductVariantMetadata($id: ID!) {
productVariant(id: $id) {
id
metadata {
key
value
}
}
}

View file

@ -1,4 +1,5 @@
import { NextWebhookApiHandler } from "@saleor/app-sdk/handlers/next"; import { NextWebhookApiHandler } from "@saleor/app-sdk/handlers/next";
import { type Client } from "urql";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { CMSSchemaChannels, CMSSchemaProviderInstances } from "../config"; import { CMSSchemaChannels, CMSSchemaProviderInstances } from "../config";
import { CmsClientOperations } from "../types"; import { CmsClientOperations } from "../types";
@ -18,6 +19,8 @@ const mockedContext: Pick<WebhookContext, "authData"> = {
}, },
}; };
const createMockedClient = () => ({} as Client);
vi.mock("../../metadata", () => ({ vi.mock("../../metadata", () => ({
createSettingsManager: () => ({}), createSettingsManager: () => ({}),
})); }));
@ -37,6 +40,7 @@ describe("CMS Clients Operations", () => {
const cmsOperations = await createCmsOperations({ const cmsOperations = await createCmsOperations({
context: mockedContext, context: mockedContext,
client: createMockedClient(),
productVariantChannels: [], productVariantChannels: [],
productVariantCmsKeys: [], productVariantCmsKeys: [],
}); });
@ -99,6 +103,7 @@ describe("CMS Clients Operations", () => {
const cmsOperations = await createCmsOperations({ const cmsOperations = await createCmsOperations({
context: mockedContext, context: mockedContext,
client: createMockedClient(),
productVariantChannels: ["default-channel"], productVariantChannels: ["default-channel"],
productVariantCmsKeys: [], productVariantCmsKeys: [],
}); });
@ -171,6 +176,7 @@ describe("CMS Clients Operations", () => {
const cmsOperations = await createCmsOperations({ const cmsOperations = await createCmsOperations({
context: mockedContext, context: mockedContext,
client: createMockedClient(),
productVariantChannels: ["default-channel"], productVariantChannels: ["default-channel"],
productVariantCmsKeys: [createCmsKeyForSaleorItem("first-provider")], productVariantCmsKeys: [createCmsKeyForSaleorItem("first-provider")],
}); });
@ -243,6 +249,7 @@ describe("CMS Clients Operations", () => {
const cmsOperations = await createCmsOperations({ const cmsOperations = await createCmsOperations({
context: mockedContext, context: mockedContext,
client: createMockedClient(),
productVariantChannels: [], productVariantChannels: [],
productVariantCmsKeys: [createCmsKeyForSaleorItem("first-provider")], productVariantCmsKeys: [createCmsKeyForSaleorItem("first-provider")],
}); });

View file

@ -12,15 +12,18 @@ import cmsProviders, { CMSProvider } from "../providers";
import { CmsClientOperations } from "../types"; import { CmsClientOperations } from "../types";
import { logger as pinoLogger } from "../../logger"; import { logger as pinoLogger } from "../../logger";
import { getCmsIdFromSaleorItemKey } from "./metadata"; import { getCmsIdFromSaleorItemKey } from "./metadata";
import { type Client } from "urql";
type WebhookContext = Parameters<NextWebhookApiHandler>["2"]; type WebhookContext = Parameters<NextWebhookApiHandler>["2"];
export const createCmsOperations = async ({ export const createCmsOperations = async ({
context, context,
client,
productVariantChannels, productVariantChannels,
productVariantCmsKeys, productVariantCmsKeys,
}: { }: {
context: Pick<WebhookContext, "authData">; context: Pick<WebhookContext, "authData">;
client: Client;
productVariantChannels: string[]; productVariantChannels: string[];
productVariantCmsKeys: string[]; productVariantCmsKeys: string[];
}) => { }) => {
@ -29,13 +32,6 @@ export const createCmsOperations = async ({
productVariantCmsKeys, productVariantCmsKeys,
}); });
const saleorApiUrl = context.authData.saleorApiUrl;
const token = context.authData.token;
const client = createClient(saleorApiUrl, async () => ({
token: token,
}));
const settingsManager = createSettingsManager(client); const settingsManager = createSettingsManager(client);
const channelsSettingsParsed = await getChannelsSettings(settingsManager); const channelsSettingsParsed = await getChannelsSettings(settingsManager);

View file

@ -7,6 +7,7 @@ type ProviderToken = {
label: string; label: string;
helpText: string; helpText: string;
required?: boolean; required?: boolean;
secret?: boolean;
}; };
type ProviderConfig = { type ProviderConfig = {
@ -26,6 +27,7 @@ export const providersConfig = {
tokens: [ tokens: [
{ {
required: true, required: true,
secret: true,
name: "token", name: "token",
label: "Token", label: "Token",
helpText: helpText:
@ -63,7 +65,7 @@ export const providersConfig = {
name: "baseUrl", name: "baseUrl",
label: "Base URL", label: "Base URL",
helpText: helpText:
"Optional content management API URL of your Contentful project. If you leave this blank, default https://api.contentful.com will be used.", "Content management API URL of your Contentful project. If you leave this blank, default https://api.contentful.com will be used.",
}, },
], ],
}, },
@ -80,6 +82,7 @@ export const providersConfig = {
}, },
{ {
required: true, required: true,
secret: true,
name: "token", name: "token",
label: "API Token (with full access)", label: "API Token (with full access)",
helpText: helpText:
@ -88,7 +91,7 @@ export const providersConfig = {
{ {
required: true, required: true,
name: "contentTypeId", name: "contentTypeId",
label: "Content Type ID", 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).',
}, },
@ -101,6 +104,7 @@ export const providersConfig = {
tokens: [ tokens: [
{ {
required: true, required: true,
secret: true,
name: "token", name: "token",
label: "API Token (with access to Content Management API)", label: "API Token (with access to Content Management API)",
helpText: helpText:
@ -117,13 +121,13 @@ export const providersConfig = {
name: "baseUrl", name: "baseUrl",
label: "Base URL", label: "Base URL",
helpText: helpText:
"Optional URL to your DatoCMS project. If you leave this blank, this URL will be inferred from your API Token.", "URL to your DatoCMS project. If you leave this blank, this URL will be inferred from your API Token.",
}, },
{ {
name: "environment", name: "environment",
label: "Environment", label: "Environment",
helpText: helpText:
"Optional environment name. If you leave this blank, default environment will be used. You can find this in your DatoCMS project settings.", "Environment name. If you leave this blank, default environment will be used. You can find this in your DatoCMS project settings.",
}, },
], ],
}, },

View file

@ -4,20 +4,27 @@ import { Client } from "urql";
import { import {
FetchAppDetailsDocument, FetchAppDetailsDocument,
FetchAppDetailsQuery, FetchAppDetailsQuery,
FetchProductVariantMetadataDocument,
FetchProductVariantMetadataQuery,
UpdateAppMetadataDocument, UpdateAppMetadataDocument,
} from "../../generated/graphql"; } from "../../generated/graphql";
import { logger as pinoLogger } from "../lib/logger";
// Function is using urql graphql client to fetch all available metadata. // Function is using urql graphql client to fetch all available metadata.
// Before returning query result, we are transforming response to list of objects with key and value fields // Before returning query result, we are transforming response to list of objects with key and value fields
// which can be used by the manager. // which can be used by the manager.
// Result of this query is cached by the manager. // Result of this query is cached by the manager.
export async function fetchAllMetadata(client: Client): Promise<MetadataEntry[]> { export async function fetchAllMetadata(client: Client): Promise<MetadataEntry[]> {
const logger = pinoLogger.child({
function: "fetchAllMetadata",
});
const { error, data } = await client const { error, data } = await client
.query<FetchAppDetailsQuery>(FetchAppDetailsDocument, {}) .query<FetchAppDetailsQuery>(FetchAppDetailsDocument, {})
.toPromise(); .toPromise();
if (error) { if (error) {
console.debug("Error during fetching the metadata: ", error); logger.debug("Error during fetching the metadata", error);
return []; return [];
} }
@ -28,13 +35,17 @@ export async function fetchAllMetadata(client: Client): Promise<MetadataEntry[]>
// Before data are send, additional query for required App ID is made. // Before data are send, additional query for required App ID is made.
// The manager will use updated entries returned by this mutation to update it's cache. // The manager will use updated entries returned by this mutation to update it's cache.
export async function mutateMetadata(client: Client, metadata: MetadataEntry[]) { export async function mutateMetadata(client: Client, metadata: MetadataEntry[]) {
const logger = pinoLogger.child({
function: "mutateMetadata",
});
// to update the metadata, ID is required // to update the metadata, ID is required
const { error: idQueryError, data: idQueryData } = await client const { error: idQueryError, data: idQueryData } = await client
.query(FetchAppDetailsDocument, {}) .query(FetchAppDetailsDocument, {})
.toPromise(); .toPromise();
if (idQueryError) { if (idQueryError) {
console.debug("Could not fetch the app id: ", idQueryError); logger.debug("Could not fetch the app id", idQueryError);
throw new Error( throw new Error(
"Could not fetch the app id. Please check if auth data for the client are valid." "Could not fetch the app id. Please check if auth data for the client are valid."
); );
@ -43,7 +54,7 @@ export async function mutateMetadata(client: Client, metadata: MetadataEntry[])
const appId = idQueryData?.app?.id; const appId = idQueryData?.app?.id;
if (!appId) { if (!appId) {
console.debug("Missing app id"); logger.debug("Missing app id");
throw new Error("Could not fetch the app ID"); throw new Error("Could not fetch the app ID");
} }
@ -55,7 +66,7 @@ export async function mutateMetadata(client: Client, metadata: MetadataEntry[])
.toPromise(); .toPromise();
if (mutationError) { if (mutationError) {
console.debug("Mutation error: ", mutationError); logger.debug("Mutation error", mutationError);
throw new Error(`Mutation error: ${mutationError.message}`); throw new Error(`Mutation error: ${mutationError.message}`);
} }
@ -78,3 +89,26 @@ export const createSettingsManager = (client: Client) => {
mutateMetadata: (metadata) => mutateMetadata(client, metadata), mutateMetadata: (metadata) => mutateMetadata(client, metadata),
}); });
}; };
export async function fetchProductVariantMetadata(
client: Client,
productId: string
): Promise<MetadataEntry[]> {
const logger = pinoLogger.child({
function: "fetchProductVariantMetadata",
productId,
});
const { error, data } = await client
.query<FetchProductVariantMetadataQuery>(FetchProductVariantMetadataDocument, {
id: productId,
})
.toPromise();
if (error) {
logger.debug("Error during fetching product metadata", error);
return [];
}
return data?.productVariant?.metadata.map((md) => ({ key: md.key, value: md.value })) || [];
}

View file

@ -97,7 +97,7 @@ const ChannelConfigurationForm = ({
<List gridTemplate={["1fr", "checkbox"]}> <List gridTemplate={["1fr", "checkbox"]}>
<ListHeader> <ListHeader>
<ListItem className={styles.item}> <ListItem className={styles.item}>
<ListItemCell>CMS provider instance</ListItemCell> <ListItemCell>CMS provider configuration</ListItemCell>
<ListItemCell>Active</ListItemCell> <ListItemCell>Active</ListItemCell>
</ListItem> </ListItem>
</ListHeader> </ListHeader>

View file

@ -87,8 +87,7 @@ const ChannelConfiguration = ({
return ( return (
<AppPaper> <AppPaper>
<Typography variant="body1" className={styles.textCenter}> <Typography variant="body1" className={styles.textCenter}>
Please create at least one provider instance before you manage its configuration in Please create at least one provider configuration before you manage its setup in channels.
channels.
<br /> <br />
<br /> <br />
Go to the <AppTabNavButton to="providers">Providers</AppTabNavButton> tab. Go to the <AppTabNavButton to="providers">Providers</AppTabNavButton> tab.

View file

@ -80,9 +80,11 @@ const ProviderInstanceConfigurationForm = <TProvider extends CMSProviderSchema>(
}; };
const fields = providersConfig[provider.name as TProvider].tokens; const fields = providersConfig[provider.name as TProvider].tokens;
const errors = formState.errors; const errors = formState.errors;
const getOptionalText = (token: Record<string, unknown>) =>
"required" in token && token.required ? "" : "*Optional. ";
return ( return (
<form onSubmit={handleSubmit(submitHandler)}> <form onSubmit={handleSubmit(submitHandler)}>
<Grid container spacing={1}> <Grid container spacing={1}>
@ -104,7 +106,7 @@ const ProviderInstanceConfigurationForm = <TProvider extends CMSProviderSchema>(
<Grid item xs={12}> <Grid item xs={12}>
<TextField <TextField
{...register("name" as Path<ProvidersSchema[TProvider]>)} {...register("name" as Path<ProvidersSchema[TProvider]>)}
label="Custom instance name *" label="Configuration name"
type="text" type="text"
name="name" name="name"
InputLabelProps={{ InputLabelProps={{
@ -112,7 +114,12 @@ const ProviderInstanceConfigurationForm = <TProvider extends CMSProviderSchema>(
}} }}
fullWidth fullWidth
error={!!errors.name} error={!!errors.name}
helperText={<>{errors.name?.message}</>} helperText={
<>
{errors.name?.message ||
"Used to differentiate configuration instance. You may create multiple instances of provider configuration, e.g. Contentful Prod, Contentful Test, etc."}
</>
}
/> />
</Grid> </Grid>
{fields.map((token) => ( {fields.map((token) => (
@ -122,8 +129,8 @@ const ProviderInstanceConfigurationForm = <TProvider extends CMSProviderSchema>(
required: "required" in token && token.required, required: "required" in token && token.required,
})} })}
// required={"required" in token && token.required} // required={"required" in token && token.required}
label={token.label + ("required" in token && token.required ? " *" : "")} label={token.label}
type="password" type={token.secret ? "password" : "text"}
name={token.name} name={token.name}
InputLabelProps={{ InputLabelProps={{
shrink: !!watch(token.name as Path<ProvidersSchema[TProvider]>), shrink: !!watch(token.name as Path<ProvidersSchema[TProvider]>),
@ -134,7 +141,11 @@ const ProviderInstanceConfigurationForm = <TProvider extends CMSProviderSchema>(
helperText={ helperText={
<> <>
{errors[token.name as Path<ProvidersSchema[TProvider]>]?.message || {errors[token.name as Path<ProvidersSchema[TProvider]>]?.message ||
("helpText" in token && <AppMarkdownText>{token.helpText}</AppMarkdownText>)} ("helpText" in token && (
<AppMarkdownText>{`${getOptionalText(token)}${
token.helpText
}`}</AppMarkdownText>
))}
</> </>
} }
/> />

View file

@ -118,7 +118,7 @@ const ProviderInstanceConfiguration = ({
return ( return (
<AppPaper> <AppPaper>
<Typography variant="body1" className={styles.textCenter}> <Typography variant="body1" className={styles.textCenter}>
Please select a provider instance or add new one. Please select a provider configuration or add new one.
</Typography> </Typography>
</AppPaper> </AppPaper>
); );
@ -133,12 +133,12 @@ const ProviderInstanceConfiguration = ({
)} )}
{errors.saving && ( {errors.saving && (
<Typography variant="body1" color="error"> <Typography variant="body1" color="error">
Error saving provider instance configuration Error saving provider configuration
</Typography> </Typography>
)} )}
{!!newProviderInstance && ( {!!newProviderInstance && (
<Typography variant="h3" className={styles.textHeader}> <Typography variant="h3" className={styles.textHeader}>
Add new instance Add new configuration
</Typography> </Typography>
)} )}
<RadioGroup value={selectedProvider?.name ?? ""} onChange={handleProviderChange}> <RadioGroup value={selectedProvider?.name ?? ""} onChange={handleProviderChange}>

View file

@ -70,7 +70,7 @@ const ProviderInstancesListItems = ({
<OffsettedList gridTemplate={["1fr", "1fr"]}> <OffsettedList gridTemplate={["1fr", "1fr"]}>
<OffsettedListHeader> <OffsettedListHeader>
<OffsettedListItem className={styles.headerItem}> <OffsettedListItem className={styles.headerItem}>
<OffsettedListItemCell>CMS provider instance</OffsettedListItemCell> <OffsettedListItemCell>CMS provider configuration</OffsettedListItemCell>
</OffsettedListItem> </OffsettedListItem>
</OffsettedListHeader> </OffsettedListHeader>
<OffsettedListBody> <OffsettedListBody>

View file

@ -85,7 +85,7 @@ const ProviderInstancesList = ({
fullWidth fullWidth
onClick={requestAddProviderInstance} onClick={requestAddProviderInstance}
> >
Add provider Add configuration
</Button> </Button>
</Grid> </Grid>
</Grid> </Grid>

View file

@ -29,16 +29,29 @@ export const Instructions = () => {
<ol className={styles.list}> <ol className={styles.list}>
<li> <li>
In the CMS App, go to the <AppTabNavButton to="providers">Providers</AppTabNavButton>{" "} In the CMS App, go to the <AppTabNavButton to="providers">Providers</AppTabNavButton>{" "}
tab to add an instance of your provider. Click <q>Add provider</q>, and select the cms tab to add a configuration of your provider. Click <q>Add configuration</q>, and
provider you want to use. Fill in the configuration form and hit <q>Save</q>. select the cms provider you want to use. Fill in the configuration form and hit{" "}
<q>Save</q>.
</li>
<li>
Go to your CMS website and prepare product variant model shape with:
<ul>
<li>
string fields: <q>saleor_id</q>, <q>name</q>, <q>product_id</q>,{" "}
<q>product_name</q>, <q>product_slug</q>,
</li>
<li>
JSON fileds: <q>channels</q>.
</li>
</ul>
</li> </li>
<li> <li>
Go to the <AppTabNavButton to="channels">Channels</AppTabNavButton> tab. Select a Go to the <AppTabNavButton to="channels">Channels</AppTabNavButton> tab. Select a
channel. In the <q>Channel cms provider</q> field, select the created instance. Fill channel. Select the CMS configurations you want to sync product variants data against
in the rest of the form, and hit <q>Save</q>. available in this channel and hit <q>Save</q>.
</li> </li>
<li> <li>
Saleor will now use the channel&#39;s configured cms provider for product Saleor will now use the channel&#39;s configured CMS provider for product variant
syncronisation once it is created, updated or deleted. syncronisation once it is created, updated or deleted.
</li> </li>
<li> <li>

View file

@ -12,6 +12,9 @@ import {
executeMetadataUpdate, executeMetadataUpdate,
} from "../../../lib/cms/client"; } from "../../../lib/cms/client";
import { logger as pinoLogger } from "../../../lib/logger"; import { logger as pinoLogger } from "../../../lib/logger";
import { createClient } from "../../../lib/graphql";
import { fetchProductVariantMetadata } from "../../../lib/metadata";
import { getCmsKeysFromSaleorItem } from "../../../lib/cms/client/metadata";
export const config = { export const config = {
api: { api: {
@ -52,6 +55,7 @@ export const handler: NextWebhookApiHandler<ProductVariantCreatedWebhookPayloadF
context context
) => { ) => {
const { productVariant } = context.payload; const { productVariant } = context.payload;
const { saleorApiUrl, token } = context.authData;
const logger = pinoLogger.child({ const logger = pinoLogger.child({
productVariant, productVariant,
@ -66,11 +70,18 @@ export const handler: NextWebhookApiHandler<ProductVariantCreatedWebhookPayloadF
}); });
} }
const client = createClient(saleorApiUrl, async () => ({
token: token,
}));
const productVariantChannels = getChannelsSlugsFromSaleorItem(productVariant); const productVariantChannels = getChannelsSlugsFromSaleorItem(productVariant);
const productVariantMetadata = await fetchProductVariantMetadata(client, productVariant.id);
const productVariantCmsKeys = getCmsKeysFromSaleorItem({ metadata: productVariantMetadata });
const cmsOperations = await createCmsOperations({ const cmsOperations = await createCmsOperations({
context, context,
client,
productVariantChannels: productVariantChannels, productVariantChannels: productVariantChannels,
productVariantCmsKeys: [], productVariantCmsKeys: productVariantCmsKeys,
}); });
const { const {

View file

@ -12,6 +12,8 @@ import {
executeMetadataUpdate, executeMetadataUpdate,
} from "../../../lib/cms/client"; } from "../../../lib/cms/client";
import { logger as pinoLogger } from "../../../lib/logger"; import { logger as pinoLogger } from "../../../lib/logger";
import { createClient } from "../../../lib/graphql";
import { fetchProductVariantMetadata } from "../../../lib/metadata";
export const config = { export const config = {
api: { api: {
@ -52,6 +54,7 @@ export const handler: NextWebhookApiHandler<ProductVariantDeletedWebhookPayloadF
context context
) => { ) => {
const { productVariant } = context.payload; const { productVariant } = context.payload;
const { saleorApiUrl, token } = context.authData;
const logger = pinoLogger.child({ const logger = pinoLogger.child({
productVariant, productVariant,
@ -66,9 +69,14 @@ export const handler: NextWebhookApiHandler<ProductVariantDeletedWebhookPayloadF
}); });
} }
const client = createClient(saleorApiUrl, async () => ({
token: token,
}));
const productVariantCmsKeys = getCmsKeysFromSaleorItem(productVariant); const productVariantCmsKeys = getCmsKeysFromSaleorItem(productVariant);
const cmsOperations = await createCmsOperations({ const cmsOperations = await createCmsOperations({
context, context,
client,
productVariantChannels: [], productVariantChannels: [],
productVariantCmsKeys: productVariantCmsKeys, productVariantCmsKeys: productVariantCmsKeys,
}); });

View file

@ -13,6 +13,8 @@ import {
executeMetadataUpdate, executeMetadataUpdate,
} from "../../../lib/cms/client"; } from "../../../lib/cms/client";
import { logger as pinoLogger } from "../../../lib/logger"; import { logger as pinoLogger } from "../../../lib/logger";
import { createClient } from "../../../lib/graphql";
import { fetchProductVariantMetadata } from "../../../lib/metadata";
export const config = { export const config = {
api: { api: {
@ -52,8 +54,8 @@ export const handler: NextWebhookApiHandler<ProductVariantUpdatedWebhookPayloadF
res, res,
context context
) => { ) => {
// * product_updated event triggers on product_created as well 🤷
const { productVariant } = context.payload; const { productVariant } = context.payload;
const { saleorApiUrl, token } = context.authData;
const logger = pinoLogger.child({ const logger = pinoLogger.child({
productVariant, productVariant,
@ -68,10 +70,16 @@ export const handler: NextWebhookApiHandler<ProductVariantUpdatedWebhookPayloadF
}); });
} }
const client = createClient(saleorApiUrl, async () => ({
token: token,
}));
const productVariantChannels = getChannelsSlugsFromSaleorItem(productVariant); const productVariantChannels = getChannelsSlugsFromSaleorItem(productVariant);
const productVariantCmsKeys = getCmsKeysFromSaleorItem(productVariant); const productVariantMetadata = await fetchProductVariantMetadata(client, productVariant.id);
const productVariantCmsKeys = getCmsKeysFromSaleorItem({ metadata: productVariantMetadata });
const cmsOperations = await createCmsOperations({ const cmsOperations = await createCmsOperations({
context, context,
client,
productVariantChannels: productVariantChannels, productVariantChannels: productVariantChannels,
productVariantCmsKeys: productVariantCmsKeys, productVariantCmsKeys: productVariantCmsKeys,
}); });