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:
parent
8f2d5e8960
commit
84df6acf9c
15 changed files with 137 additions and 37 deletions
|
@ -0,0 +1,9 @@
|
|||
query FetchProductVariantMetadata($id: ID!) {
|
||||
productVariant(id: $id) {
|
||||
id
|
||||
metadata {
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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")],
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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.",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
@ -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 })) || [];
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -85,7 +85,7 @@ const ProviderInstancesList = ({
|
|||
fullWidth
|
||||
onClick={requestAddProviderInstance}
|
||||
>
|
||||
Add provider
|
||||
Add configuration
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
|
|
@ -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's configured cms provider for product
|
||||
Saleor will now use the channel's configured CMS provider for product variant
|
||||
syncronisation once it is created, updated or deleted.
|
||||
</li>
|
||||
<li>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue