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

View file

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

View file

@ -7,6 +7,7 @@ type ProviderToken = {
label: string;
helpText: string;
required?: boolean;
secret?: boolean;
};
type ProviderConfig = {
@ -26,6 +27,7 @@ export const providersConfig = {
tokens: [
{
required: true,
secret: true,
name: "token",
label: "Token",
helpText:
@ -63,7 +65,7 @@ export const providersConfig = {
name: "baseUrl",
label: "Base URL",
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,
secret: true,
name: "token",
label: "API Token (with full access)",
helpText:
@ -88,7 +91,7 @@ export const providersConfig = {
{
required: true,
name: "contentTypeId",
label: "Content Type ID",
label: "Content Type ID (plural)",
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).',
},
@ -101,6 +104,7 @@ export const providersConfig = {
tokens: [
{
required: true,
secret: true,
name: "token",
label: "API Token (with access to Content Management API)",
helpText:
@ -117,13 +121,13 @@ export const providersConfig = {
name: "baseUrl",
label: "Base URL",
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",
label: "Environment",
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 {
FetchAppDetailsDocument,
FetchAppDetailsQuery,
FetchProductVariantMetadataDocument,
FetchProductVariantMetadataQuery,
UpdateAppMetadataDocument,
} from "../../generated/graphql";
import { logger as pinoLogger } from "../lib/logger";
// 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
// which can be used by the manager.
// Result of this query is cached by the manager.
export async function fetchAllMetadata(client: Client): Promise<MetadataEntry[]> {
const logger = pinoLogger.child({
function: "fetchAllMetadata",
});
const { error, data } = await client
.query<FetchAppDetailsQuery>(FetchAppDetailsDocument, {})
.toPromise();
if (error) {
console.debug("Error during fetching the metadata: ", error);
logger.debug("Error during fetching the metadata", error);
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.
// The manager will use updated entries returned by this mutation to update it's cache.
export async function mutateMetadata(client: Client, metadata: MetadataEntry[]) {
const logger = pinoLogger.child({
function: "mutateMetadata",
});
// to update the metadata, ID is required
const { error: idQueryError, data: idQueryData } = await client
.query(FetchAppDetailsDocument, {})
.toPromise();
if (idQueryError) {
console.debug("Could not fetch the app id: ", idQueryError);
logger.debug("Could not fetch the app id", idQueryError);
throw new Error(
"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;
if (!appId) {
console.debug("Missing app id");
logger.debug("Missing app id");
throw new Error("Could not fetch the app ID");
}
@ -55,7 +66,7 @@ export async function mutateMetadata(client: Client, metadata: MetadataEntry[])
.toPromise();
if (mutationError) {
console.debug("Mutation error: ", mutationError);
logger.debug("Mutation error", mutationError);
throw new Error(`Mutation error: ${mutationError.message}`);
}
@ -78,3 +89,26 @@ export const createSettingsManager = (client: Client) => {
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"]}>
<ListHeader>
<ListItem className={styles.item}>
<ListItemCell>CMS provider instance</ListItemCell>
<ListItemCell>CMS provider configuration</ListItemCell>
<ListItemCell>Active</ListItemCell>
</ListItem>
</ListHeader>

View file

@ -87,8 +87,7 @@ const ChannelConfiguration = ({
return (
<AppPaper>
<Typography variant="body1" className={styles.textCenter}>
Please create at least one provider instance before you manage its configuration in
channels.
Please create at least one provider configuration before you manage its setup in channels.
<br />
<br />
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 errors = formState.errors;
const getOptionalText = (token: Record<string, unknown>) =>
"required" in token && token.required ? "" : "*Optional. ";
return (
<form onSubmit={handleSubmit(submitHandler)}>
<Grid container spacing={1}>
@ -104,7 +106,7 @@ const ProviderInstanceConfigurationForm = <TProvider extends CMSProviderSchema>(
<Grid item xs={12}>
<TextField
{...register("name" as Path<ProvidersSchema[TProvider]>)}
label="Custom instance name *"
label="Configuration name"
type="text"
name="name"
InputLabelProps={{
@ -112,7 +114,12 @@ const ProviderInstanceConfigurationForm = <TProvider extends CMSProviderSchema>(
}}
fullWidth
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>
{fields.map((token) => (
@ -122,8 +129,8 @@ const ProviderInstanceConfigurationForm = <TProvider extends CMSProviderSchema>(
required: "required" in token && token.required,
})}
// required={"required" in token && token.required}
label={token.label + ("required" in token && token.required ? " *" : "")}
type="password"
label={token.label}
type={token.secret ? "password" : "text"}
name={token.name}
InputLabelProps={{
shrink: !!watch(token.name as Path<ProvidersSchema[TProvider]>),
@ -134,7 +141,11 @@ const ProviderInstanceConfigurationForm = <TProvider extends CMSProviderSchema>(
helperText={
<>
{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 (
<AppPaper>
<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>
</AppPaper>
);
@ -133,12 +133,12 @@ const ProviderInstanceConfiguration = ({
)}
{errors.saving && (
<Typography variant="body1" color="error">
Error saving provider instance configuration
Error saving provider configuration
</Typography>
)}
{!!newProviderInstance && (
<Typography variant="h3" className={styles.textHeader}>
Add new instance
Add new configuration
</Typography>
)}
<RadioGroup value={selectedProvider?.name ?? ""} onChange={handleProviderChange}>

View file

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

View file

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

View file

@ -29,16 +29,29 @@ export const Instructions = () => {
<ol className={styles.list}>
<li>
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
provider you want to use. Fill in the configuration form and hit <q>Save</q>.
tab to add a configuration of your provider. Click <q>Add configuration</q>, and
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>
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
in the rest of the form, and hit <q>Save</q>.
channel. Select the CMS configurations you want to sync product variants data against
available in this channel and hit <q>Save</q>.
</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.
</li>
<li>

View file

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

View file

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

View file

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