CMS v2 quality improvements (#782)

* Add required validation for forms

* Add logs

* [skip-ci] handle form errors

* Improve notifications after form created

* add notification when bulk sync finishes

* Add skeletons

* Validation for channel connection form

* cr fixes
This commit is contained in:
Lukasz Ostrowski 2023-07-19 10:22:00 +02:00 committed by GitHub
parent 44333a6784
commit 5d41af93e1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 541 additions and 240 deletions

View file

@ -0,0 +1,5 @@
---
"saleor-app-cms-v2": minor
---
Added validation for channel-provider connection. Now form will display error when user tries to add a connection that already exists.

View file

@ -0,0 +1,10 @@
---
"saleor-app-cms-v2": minor
---
Added set of improvements around app quality
- Ensured forms have fields properly set as "required", so form validation will prevent empty form submissions
- Contentful and DatoCMS forms now validate the credentials.
- Added logs (server side) in various places
- Bulk sync finished now triggers notification

View file

@ -0,0 +1,5 @@
---
"saleor-app-cms-v2": minor
---
Added skeletons instead raw "Loading" texts. Also removed unnecessary warning message in bulk sync section

View file

@ -8,6 +8,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { ButtonsBox } from "../ui/buttons-box";
import { ProvidersResolver } from "../providers/providers-resolver";
import { Skeleton } from "../ui/skeleton";
const FormSchema = z.object({
connectionId: z.string().min(7),
@ -33,7 +34,7 @@ export const BulkSyncSection = () => {
const { data: connections } = trpcClient.channelsProvidersConnection.fetchConnections.useQuery();
const { data: providers } = trpcClient.providersConfigs.getAll.useQuery();
const { control, handleSubmit } = useForm<z.infer<typeof FormSchema>>({
const { control, handleSubmit } = useForm({
defaultValues: {
connectionId: "",
},
@ -41,7 +42,7 @@ export const BulkSyncSection = () => {
});
if (!connections || !providers) {
return <Text>Loading...</Text>;
return <Skeleton.Section />;
}
if (connections.length === 0) {

View file

@ -10,6 +10,7 @@ import { useBulkSyncProductsState } from "./use-bulk-sync-products-state";
import { useFetchAllProducts } from "./use-fetch-all-products";
import { VariantsSyncStatusList } from "./variants-sync-status-list";
import { ProvidersResolver } from "../providers/providers-resolver";
import { useDashboardNotification } from "@saleor/apps-shared";
const FetchProductsStep = (props: { onButtonClick(): void }) => {
return (
@ -61,13 +62,21 @@ export const BulkSyncView = ({
}) => {
const processor = useRef(ProvidersResolver.createBulkSyncProcessor(configuration));
const [state, setState] = useState<Status>("initial");
const { notifySuccess } = useDashboardNotification();
const { products, finished: saleorProductsFetchFinished } = useFetchAllProducts(
state === "fetching",
connection.channelSlug
);
const { productsStatusList, setInitialProducts, setItemStatus } = useBulkSyncProductsState();
const { productsStatusList, setInitialProducts, setItemStatus, finished } =
useBulkSyncProductsState();
useEffect(() => {
if (finished) {
notifySuccess("Bulk sync ended", "All products have been synced, please verify results");
}
}, [finished, notifySuccess]);
useEffect(() => {
if (!saleorProductsFetchFinished) {
@ -91,7 +100,7 @@ export const BulkSyncView = ({
setItemStatus(variantId, "success");
},
onUploadError({ error, variantId }) {
// todo handle error
// User will be notified about the error in the UI
setItemStatus(variantId, "error");
},
});

View file

@ -1,13 +1,21 @@
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { BulkImportProductFragment } from "../../../generated/graphql";
import { VariantsSyncStatusListItem } from "./variants-sync-status-list";
export const useBulkSyncProductsState = () => {
const [finished, setFinished] = useState(false);
const [productsStatusList, setProductsStatusList] = useState<VariantsSyncStatusListItem[] | null>(
null
);
useEffect(() => {
if (productsStatusList?.every((item) => item.status === "success" || item.status === "error")) {
setFinished(true);
}
}, [productsStatusList]);
return {
finished,
productsStatusList,
setInitialProducts: useCallback((products: BulkImportProductFragment[]) => {
setProductsStatusList(

View file

@ -1,9 +1,10 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowRightIcon, Box } from "@saleor/macaw-ui/next";
import { ArrowRightIcon, Box, Text } from "@saleor/macaw-ui/next";
import { Select } from "@saleor/react-hook-form-macaw";
import { useForm } from "react-hook-form";
import { ChannelProviderConnectionConfig } from "../configuration";
import { trpcClient } from "../trpc/trpc-client";
import { TRPCError } from "@trpc/server";
export type AddConnectionFormSchema = Omit<
ChannelProviderConnectionConfig.InputShape,
@ -14,28 +15,42 @@ export const AddConnectionFormID = "new-connection-form";
export const AddConnectionForm = (props: {
defaultValues: AddConnectionFormSchema;
onSubmit(values: AddConnectionFormSchema): void;
onSubmit(values: AddConnectionFormSchema): Promise<void>;
}) => {
const { data: channels } = trpcClient.channelsProvidersConnection.fetchAllChannels.useQuery();
const { data: providers } = trpcClient.providersConfigs.getAll.useQuery();
const { handleSubmit, control } = useForm<AddConnectionFormSchema>({
const {
handleSubmit,
control,
setError,
formState: { errors },
} = useForm({
defaultValues: props.defaultValues,
resolver: zodResolver(
ChannelProviderConnectionConfig.Schema.Input.omit({ providerType: true })
),
});
const onSubmit = (values: AddConnectionFormSchema) => {
props.onSubmit(values).catch((err: TRPCError) => {
setError("channelSlug", {
type: "manual",
});
setError("providerId", {
type: "manual",
});
setError("root.serverError", {
type: "manual",
message: err.message,
});
});
};
return (
<Box
onSubmit={handleSubmit(props.onSubmit)}
as="form"
id={AddConnectionFormID}
display="grid"
__gridTemplateColumns="1fr auto 1fr"
gap={4}
alignItems="center"
>
<Box onSubmit={handleSubmit(onSubmit)} as="form" id={AddConnectionFormID}>
<Box display="grid" __gridTemplateColumns="1fr auto 1fr" gap={4} alignItems="center">
<Select
required
size="small"
@ -60,5 +75,11 @@ export const AddConnectionForm = (props: {
}))}
/>
</Box>
{errors.root?.serverError && (
<Text as="p" marginTop={2} color="textCriticalSubdued">
{errors.root?.serverError.message}
</Text>
)}
</Box>
);
};

View file

@ -6,13 +6,39 @@ import {
AddConnectionFormID,
AddConnectionFormSchema,
} from "./add-connection-form";
import { trpcClient } from "../trpc/trpc-client";
import { Skeleton } from "../ui/skeleton";
const defaultValues: AddConnectionFormSchema = { channelSlug: "", providerId: "" };
export const AddConnectionModal = (props: {
onSubmit(values: AddConnectionFormSchema): void;
onClose(): void;
}) => (
export const AddConnectionModal = (props: { onSuccess(): void; onClose(): void }) => {
const { data: providers } = trpcClient.providersConfigs.getAll.useQuery();
if (!providers) {
return <Skeleton.Section />;
}
const { mutateAsync: addProviderMutate, isLoading } =
trpcClient.channelsProvidersConnection.addConnection.useMutation({
onSuccess() {
props.onSuccess();
},
});
const handleFormSubmit = async (values: AddConnectionFormSchema) => {
const providerType = providers.find((p) => p.id === values.providerId)?.type;
if (!providerType) {
throw new Error("Provider not found");
}
return addProviderMutate({
...values,
providerType,
});
};
return (
<Modal onClose={props.onClose}>
<Text as="h2" variant="heading">
Connect channel with Provider
@ -21,9 +47,10 @@ export const AddConnectionModal = (props: {
Once connected, operations on product variants on this channel will be sent to selected CMS
platform.
</Text>
<AddConnectionForm onSubmit={props.onSubmit} defaultValues={defaultValues} />
<AddConnectionForm onSubmit={handleFormSubmit} defaultValues={defaultValues} />
<ButtonsBox marginTop={8}>
<Button
disabled={isLoading}
variant="tertiary"
onClick={() => {
props.onClose();
@ -37,3 +64,4 @@ export const AddConnectionModal = (props: {
</ButtonsBox>
</Modal>
);
};

View file

@ -5,6 +5,7 @@ import { protectedClientProcedure } from "@/modules/trpc/protected-client-proced
import { router } from "@/modules/trpc/trpc-server";
import { z } from "zod";
import { FetchChannelsDocument } from "../../../generated/graphql";
import { TRPCError } from "@trpc/server";
const procedure = protectedClientProcedure.use(({ ctx, next }) => {
const settingsManager = createSettingsManager(ctx.apiClient, ctx.appId!);
@ -33,7 +34,24 @@ export const channelProviderConnectionRouter = router({
.mutation(async ({ ctx, input }) => {
const config = await ctx.appConfigService.get();
try {
config.connections.addConnection(input);
} catch (e) {
switch ((e as { cause: string }).cause) {
case "PROVIDER_DOESNT_EXIST":
throw new TRPCError({
code: "BAD_REQUEST",
cause: "PROVIDER_DOESNT_EXIST",
message: "Provider doesnt exist",
});
case "CONNECTION_ALREADY_EXISTS":
throw new TRPCError({
code: "CONFLICT",
cause: "CONNECTION_EXISTS",
message: "Connection already exists",
});
}
}
ctx.appConfigService.set(config);
}),

View file

@ -7,6 +7,7 @@ import { AddConnectionFormSchema } from "./add-connection-form";
import { AddConnectionModal } from "./add-connection-modal";
import { ChanelProviderConnectionsSectionHeader } from "./channel-provider-connections-section-header";
import { ConnectionsList } from "./connections-list";
import { Skeleton } from "../ui/skeleton";
const NoConnections = (props: { onCreate(): void; enabled: boolean }) => (
<Box>
@ -27,12 +28,13 @@ const NoConnections = (props: { onCreate(): void; enabled: boolean }) => (
export const ChannelProviderConnectionList = () => {
const [dialogOpen, setDialogOpen] = useState(false);
const { data: connectionsData, refetch } =
const { data: connectionsData, refetch: refetchConnections } =
trpcClient.channelsProvidersConnection.fetchConnections.useQuery();
const { mutate: removeConnection } =
trpcClient.channelsProvidersConnection.removeConnection.useMutation({
onSuccess() {
refetch();
refetchConnections();
notifySuccess("Success", "Removed connection");
},
});
@ -42,32 +44,10 @@ export const ChannelProviderConnectionList = () => {
trpcClient.channelsProvidersConnection.fetchAllChannels.useQuery();
const { data: providers } = trpcClient.providersConfigs.getAll.useQuery();
const { mutate: addProviderMutate } =
trpcClient.channelsProvidersConnection.addConnection.useMutation({
onSuccess() {
notifySuccess("Success", "Added connection");
refetch();
setDialogOpen(false);
},
});
if (!providers) {
return null;
return <Skeleton.Section />;
}
const handleFormSubmit = (values: AddConnectionFormSchema) => {
const providerType = providers.find((p) => p.id === values.providerId)?.type;
if (!providerType) {
throw new Error("Provider not found");
}
addProviderMutate({
...values,
providerType,
});
};
const handleDelete = (connectionId: string) => {
removeConnection({ id: connectionId });
};
@ -83,7 +63,11 @@ export const ChannelProviderConnectionList = () => {
onClose={() => {
setDialogOpen(false);
}}
onSubmit={handleFormSubmit}
onSuccess={() => {
refetchConnections();
notifySuccess("Success", "Connection created");
setDialogOpen(false);
}}
/>
)}
{connectionsData.length === 0 && (

View file

@ -4,6 +4,7 @@ import React from "react";
import { trpcClient } from "../trpc/trpc-client";
import { ChanelProviderConnectionsSectionHeader } from "./channel-provider-connections-section-header";
import { ProvidersResolver } from "../providers/providers-resolver";
import { Skeleton } from "../ui/skeleton";
export const ConnectionsList = (props: { onRemove(connectionId: string): void }) => {
const { data } = trpcClient.channelsProvidersConnection.fetchConnections.useQuery();
@ -11,7 +12,7 @@ export const ConnectionsList = (props: { onRemove(connectionId: string): void })
const { data: providers } = trpcClient.providersConfigs.getAll.useQuery();
if (!data || !providers) {
return null;
return <Skeleton.Section />;
}
return (

View file

@ -3,6 +3,11 @@ import { generateId } from "../shared/generate-id";
import { ChannelProviderConnectionConfig } from "./schemas/channel-provider-connection.schema";
import { ProvidersConfig, RootConfig } from "./schemas/root-config.schema";
export const Errors = {
CONNECTION_ALREADY_EXISTS: "CONNECTION_ALREADY_EXISTS",
PROVIDER_DOESNT_EXIST: "PROVIDER_DOESNT_EXIST",
};
/**
* TODO
* - test
@ -89,11 +94,23 @@ export class AppConfig {
addConnection: (input: ChannelProviderConnectionConfig.InputShape) => {
if (!this.providers.checkProviderExists(input.providerId)) {
throw new Error("Provider doesnt exist");
const err = new Error("Provider doesnt exist");
err.cause = Errors.PROVIDER_DOESNT_EXIST;
throw err;
}
const parsed = ChannelProviderConnectionConfig.Schema.Input.parse(input);
if (this.checkConnectionExists(input)) {
const error = new Error("Connection already exists");
error.cause = Errors.CONNECTION_ALREADY_EXISTS;
throw error;
}
this.rootData.connections.push({
...parsed,
id: generateId(),
@ -112,4 +129,20 @@ export class AppConfig {
return this.connections.getConnections().find((c) => c.id === id);
},
};
/**
* Returns true if exists
*/
private checkConnectionExists(newConnection: ChannelProviderConnectionConfig.InputShape) {
/**
* Make array of strings so its easy to compare
*/
const existingConnectionsAsStrings = this.connections
.getConnections()
.map((connection) => `${connection.channelSlug}-${connection.providerId}`);
const newConnectionAsString = `${newConnection.channelSlug}-${newConnection.providerId}`;
return existingConnectionsAsStrings.includes(newConnectionAsString);
}
}

View file

@ -4,11 +4,11 @@ import { SaleorProviderFieldsMappingSchema } from "./saleor-provider-fields-mapp
const InputSchema = z.object({
type: z.literal(Contentful.type),
authToken: z.string(),
spaceId: z.string(),
environment: z.string(),
configName: z.string(),
contentId: z.string(),
authToken: z.string().min(1),
spaceId: z.string().min(1),
environment: z.string().min(1),
configName: z.string().min(1),
contentId: z.string().min(1),
productVariantFieldsMapping: SaleorProviderFieldsMappingSchema,
});

View file

@ -4,9 +4,9 @@ import { SaleorProviderFieldsMappingSchema } from "./saleor-provider-fields-mapp
const InputSchema = z.object({
type: z.literal(Datocms.type),
authToken: z.string(),
configName: z.string(),
itemType: z.string(),
authToken: z.string().min(1),
configName: z.string().min(1),
itemType: z.string().min(1),
productVariantFieldsMapping: SaleorProviderFieldsMappingSchema,
});

View file

@ -5,7 +5,6 @@ import { BuilderIoProviderConfig } from "./builder-provider.schema";
import { StrapiProviderConfig } from "./strapi-provider.schema";
import { DatocmsProviderConfig } from "./datocms-provider.schema";
// todo move to shared bootstrap?
export namespace ProvidersConfig {
const AnyFull = z.union([
/**

View file

@ -5,9 +5,9 @@ import { SaleorProviderFieldsMappingSchema } from "./saleor-provider-fields-mapp
const InputSchema = z.object({
configName: z.string(),
type: z.literal(Strapi.type),
url: z.string().url(),
authToken: z.string(),
itemType: z.string(),
url: z.string().url().min(1),
authToken: z.string().min(1),
itemType: z.string().min(1),
productVariantFieldsMapping: SaleorProviderFieldsMappingSchema,
});

View file

@ -6,6 +6,7 @@ import { ProvidersConfig } from "../configuration";
import { ProvidersResolver } from "../providers/providers-resolver";
import { trpcClient } from "../trpc/trpc-client";
import { ButtonsBox } from "../ui/buttons-box";
import { Skeleton } from "../ui/skeleton";
const ProvidersTable = (props: { providers: ProvidersConfig.AnyFullShape[] }) => {
const { push } = useRouter();
@ -46,7 +47,7 @@ export const ProvidersList = () => {
const { push } = useRouter();
if (!data) {
return null;
return <Skeleton.Section />;
}
if (data.length === 0) {

View file

@ -9,6 +9,7 @@ import { printSaleorProductFields } from "../../configuration/print-saleor-produ
import { trpcClient } from "../../trpc/trpc-client";
import { ButtonsBox } from "../../ui/buttons-box";
import { TextLink } from "@saleor/apps-ui";
import { Skeleton } from "@/modules/ui/skeleton";
type FormShape = Omit<BuilderIoProviderConfig.InputShape, "type">;
const FormSchema = BuilderIoProviderConfig.Schema.Input.omit({ type: true });
@ -20,7 +21,7 @@ type PureFormProps = {
};
const PureForm = ({ defaultValues, onSubmit, onDelete }: PureFormProps) => {
const { control, handleSubmit } = useForm<FormShape>({
const { control, handleSubmit } = useForm({
defaultValues: defaultValues,
resolver: zodResolver(FormSchema),
});
@ -146,7 +147,7 @@ const AddFormVariant = () => {
const { notifySuccess } = useDashboardNotification();
const { mutate: addProvider } = trpcClient.providersConfigs.addOne.useMutation({
onSuccess() {
notifySuccess("Success", "Updated configuration");
notifySuccess("Success", "Saved configuration");
push("/configuration");
},
});
@ -204,7 +205,7 @@ const EditFormVariant = (props: { configId: string }) => {
});
if (!data) {
return null;
return <Skeleton.Section />;
}
if (data.type !== "builder.io") {
@ -230,10 +231,6 @@ const EditFormVariant = (props: { configId: string }) => {
);
};
/*
* todo make the same with contentful
* todo improve copy
*/
export const BuilderIoConfigForm = {
PureVariant: PureForm,
AddVariant: AddFormVariant,

View file

@ -1,6 +1,7 @@
import { BuilderIoProviderConfig } from "@/modules/configuration";
import { WebhookProductVariantFragment } from "../../../../generated/graphql";
import { createLogger } from "@saleor/apps-shared";
import { FieldsMapper } from "../fields-mapper";
// https://www.builder.io/c/docs/write-api
export class BuilderIoClient {
@ -12,17 +13,10 @@ export class BuilderIoClient {
}
private mapVariantToFields(variant: WebhookProductVariantFragment) {
const { channels, productId, productName, productSlug, variantId, variantName } =
this.config.productVariantFieldsMapping;
return {
[channels]: variant.channelListings,
[productId]: variant.product.id,
[productName]: variant.product.name,
[productSlug]: variant.product.slug,
[variantId]: variant.id,
[variantName]: variant.name,
};
return FieldsMapper.mapProductVariantToConfigurationFields({
variant,
configMapping: this.config.productVariantFieldsMapping,
});
}
async uploadProductVariant(variant: WebhookProductVariantFragment) {

View file

@ -30,8 +30,6 @@ const defaultSdkClientFactory: SdkClientFactory = (opts) =>
/**
* Wrapper facade of
* https://www.npmjs.com/package/contentful
*
* TODO: tests
*/
export class ContentfulClient {
private client: ContentfulApiClientChunk;
@ -46,7 +44,7 @@ export class ContentfulClient {
}
/**
* Support on en-US locale now
* Support only en-US locale now
*/
private mapVariantToConfiguredFields = (
variant: WebhookProductVariantFragment,

View file

@ -17,7 +17,6 @@ type FormSchema = Omit<ContentfulProviderConfig.InputShape, "type">;
/**
* TODO - when space, token or env changes, refetch queries
* TODO - error handling
* TODO - refactor smaller hooks
*/
const PureForm = ({
@ -29,14 +28,9 @@ const PureForm = ({
onSubmit(values: FormSchema): void;
onDelete?(): void;
}) => {
const {
control,
getValues,
setValue,
watch,
handleSubmit,
formState: { errors },
} = useForm<FormSchema>({
const { notifyError } = useDashboardNotification();
const { control, getValues, setValue, watch, handleSubmit, setError, clearErrors } = useForm({
defaultValues: defaultValues,
resolver: zodResolver(ContentfulProviderConfig.Schema.Input.omit({ type: true })),
});
@ -45,6 +39,22 @@ const PureForm = ({
trpcClient.contentful.fetchContentTypesFromApi.useMutation({
onSuccess(data) {
setValue("contentId", data.items[0].sys.id ?? null);
clearErrors(["authToken", "spaceId"]);
},
onError() {
setError("authToken", {
type: "custom",
message: "Invalid credentials",
});
setError("spaceId", {
type: "custom",
message: "Invalid credentials",
});
notifyError(
"Error",
"Could not fetch content types from Contentful. Please check your credentials."
);
},
});
@ -52,6 +62,22 @@ const PureForm = ({
trpcClient.contentful.fetchEnvironmentsFromApi.useMutation({
onSuccess(data) {
setValue("environment", data.items[0].sys.id);
clearErrors(["authToken", "spaceId"]);
},
onError() {
setError("authToken", {
type: "custom",
message: "Invalid credentials",
});
setError("spaceId", {
type: "custom",
message: "Invalid credentials",
});
notifyError(
"Error",
"Could not fetch environments from Contentful. Please check your credentials."
);
},
});
@ -289,7 +315,7 @@ const AddVariant = () => {
const { mutate } = trpcClient.providersConfigs.addOne.useMutation({
onSuccess() {
notifySuccess("Success", "Updated configuration");
notifySuccess("Success", "Saved configuration");
push("/configuration");
},
});

View file

@ -1,3 +1,4 @@
import { createLogger } from "@saleor/apps-shared";
import {
WebhookProductFragment,
WebhookProductVariantFragment,
@ -17,6 +18,7 @@ export type ContentfulClientFactory = (
export class ContentfulWebhooksProcessor implements ProductWebhooksProcessor {
private client: ContentfulClientStrip;
private logger = createLogger({ name: "ContentfulWebhooksProcessor" });
constructor(
private providerConfig: ContentfulProviderConfig.FullShape,
@ -30,18 +32,24 @@ export class ContentfulWebhooksProcessor implements ProductWebhooksProcessor {
}
async onProductVariantUpdated(productVariant: WebhookProductVariantFragment): Promise<void> {
this.logger.trace("onProductVariantUpdated called");
await this.client.upsertProductVariant({
configuration: this.providerConfig,
variant: productVariant,
});
}
async onProductVariantCreated(productVariant: WebhookProductVariantFragment): Promise<void> {
this.logger.trace("onProductVariantCreated called");
await this.client.upsertProductVariant({
configuration: this.providerConfig,
variant: productVariant,
});
}
async onProductVariantDeleted(productVariant: WebhookProductVariantFragment): Promise<void> {
this.logger.trace("onProductVariantDeleted called");
await this.client.deleteProductVariant({
configuration: this.providerConfig,
variant: productVariant,
@ -49,6 +57,8 @@ export class ContentfulWebhooksProcessor implements ProductWebhooksProcessor {
}
async onProductUpdated(product: WebhookProductFragment): Promise<void> {
this.logger.trace("onProductUpdated called");
await Promise.all(
(product.variants ?? []).map((variant) => {
return this.client.upsertProductVariant({

View file

@ -7,6 +7,8 @@ import { protectedClientProcedure } from "../../trpc/protected-client-procedure"
import { router } from "../../trpc/trpc-server";
import { ContentfulClient } from "./contentful-client";
import { createFlatProxy } from "@trpc/server/shared";
import { createLogger } from "@saleor/apps-shared";
const procedure = protectedClientProcedure.use(({ ctx, next }) => {
const settingsManager = createSettingsManager(ctx.apiClient, ctx.appId!);
@ -15,6 +17,7 @@ const procedure = protectedClientProcedure.use(({ ctx, next }) => {
ctx: {
settingsManager,
appConfigService: new AppConfigMetadataManager(settingsManager),
logger: createLogger({ name: "contentfulRouter" }),
},
});
});
@ -38,7 +41,11 @@ export const contentfulRouter = router({
space: input.contentfulSpace,
});
return client.getEnvironments();
return client.getEnvironments().catch((e) => {
ctx.logger.error("Failed to fetch environments");
throw new TRPCError({ code: "BAD_REQUEST" });
});
}),
fetchContentTypesFromApi: procedure
.input(
@ -55,7 +62,7 @@ export const contentfulRouter = router({
});
return client.getContentTypes(input.contentfulEnv).catch((e) => {
console.error(e);
ctx.logger.error("Failed to fetch content types");
throw new TRPCError({ code: "BAD_REQUEST" });
});

View file

@ -3,6 +3,7 @@ import { BulkImportProductFragment } from "../../../../generated/graphql";
import { BulkSyncProcessor, BulkSyncProcessorHooks } from "../../bulk-sync/bulk-sync-processor";
import { DatoCMSClient } from "./datocms-client";
import { createLogger } from "@saleor/apps-shared";
export class DatocmsBulkSyncProcessor implements BulkSyncProcessor {
constructor(private config: DatocmsProviderConfig.FullShape) {}

View file

@ -5,6 +5,7 @@ import { z } from "zod";
import * as Sentry from "@sentry/nextjs";
import { DatocmsProviderConfig } from "@/modules/configuration/schemas/datocms-provider.schema";
import { FieldsMapper } from "../fields-mapper";
type Context = {
configuration: DatocmsProviderConfig.FullShape;
@ -61,16 +62,21 @@ export class DatoCMSClient {
configuration,
variant,
}: Context): SimpleSchemaTypes.ItemCreateSchema {
const fieldsMap = configuration.productVariantFieldsMapping;
const fields = FieldsMapper.mapProductVariantToConfigurationFields({
variant,
configMapping: configuration.productVariantFieldsMapping,
});
/**
* Dato requires JSON to be stringified first so overwrite this single fields
*/
fields[configuration.productVariantFieldsMapping.channels] = JSON.stringify(
variant.channelListings
);
return {
item_type: { type: "item_type", id: configuration.itemType },
[fieldsMap.variantName]: variant.name,
[fieldsMap.productId]: variant.product.id,
[fieldsMap.productName]: variant.product.name,
[fieldsMap.productSlug]: variant.product.slug,
[fieldsMap.variantId]: variant.id,
[fieldsMap.channels]: JSON.stringify(variant.channelListings),
...fields,
};
}

View file

@ -19,12 +19,52 @@ type PureFormProps = {
onDelete?(): void;
};
const useDatoCmsRemoteFields = () => {
/*
* todo react on token change, refresh mutation
*/
const PureForm = ({ defaultValues, onSubmit, onDelete }: PureFormProps) => {
const { notifyError } = useDashboardNotification();
const { control, getValues, setValue, watch, handleSubmit, clearErrors, setError } = useForm({
defaultValues: defaultValues,
resolver: zodResolver(DatocmsProviderConfig.Schema.Input.omit({ type: true })),
});
const { mutate: fetchContentTypes, data: contentTypesData } =
trpcClient.datocms.fetchContentTypes.useMutation();
trpcClient.datocms.fetchContentTypes.useMutation({
onSuccess(data) {
clearErrors("authToken");
},
onError() {
setError("authToken", {
type: "custom",
message: "Invalid credentials",
});
notifyError(
"Error",
"Could not fetch content types from DatoCMS. Please check your credentials."
);
},
});
const { mutate: fetchContentTypeFields, data: fieldsData } =
trpcClient.datocms.fetchContentTypeFields.useMutation();
trpcClient.datocms.fetchContentTypeFields.useMutation({
onSuccess(data) {
clearErrors("authToken");
},
onError() {
setError("authToken", {
type: "custom",
message: "Invalid credentials",
});
notifyError(
"Error",
"Could not fetch content types from DatoCMS. Please check your credentials."
);
},
});
const contentTypesSelectOptions = useMemo(() => {
if (!contentTypesData) {
@ -37,40 +77,6 @@ const useDatoCmsRemoteFields = () => {
}));
}, [contentTypesData]);
return {
fetchContentTypes,
contentTypesData,
contentTypesSelectOptions,
fetchContentTypeFields,
fieldsData,
};
};
/*
* todo react on token error
* todo react on token change, refresh mutation
*/
const PureForm = ({ defaultValues, onSubmit, onDelete }: PureFormProps) => {
const {
control,
getValues,
setValue,
watch,
handleSubmit,
formState: { errors },
} = useForm<FormShape>({
defaultValues: defaultValues,
resolver: zodResolver(DatocmsProviderConfig.Schema.Input.omit({ type: true })),
});
const {
contentTypesData,
fetchContentTypes,
contentTypesSelectOptions,
fetchContentTypeFields,
fieldsData,
} = useDatoCmsRemoteFields();
const selectedContentType = watch("itemType");
useEffect(() => {
@ -225,7 +231,7 @@ const AddFormVariant = () => {
const { notifySuccess } = useDashboardNotification();
const { mutate: addProvider } = trpcClient.providersConfigs.addOne.useMutation({
onSuccess() {
notifySuccess("Success", "Updated configuration");
notifySuccess("Success", "Saved configuration");
push("/configuration");
},
});

View file

@ -6,12 +6,14 @@ import {
import { ProductWebhooksProcessor } from "../../webhooks-operations/product-webhooks-processor";
import { DatoCMSClient } from "./datocms-client";
import { createLogger } from "@saleor/apps-shared";
/*
* todo error handling
*/
export class DatocmsWebhooksProcessor implements ProductWebhooksProcessor {
private client: DatoCMSClient;
private logger = createLogger({ name: "DatocmsWebhooksProcessor" });
constructor(private providerConfig: DatocmsProviderConfig.FullShape) {
this.client = new DatoCMSClient({
@ -20,18 +22,25 @@ export class DatocmsWebhooksProcessor implements ProductWebhooksProcessor {
}
async onProductVariantUpdated(productVariant: WebhookProductVariantFragment): Promise<void> {
this.logger.trace("onProductVariantUpdated called");
await this.client.updateProductVariant({
configuration: this.providerConfig,
variant: productVariant,
});
}
async onProductVariantCreated(productVariant: WebhookProductVariantFragment): Promise<void> {
this.logger.trace("onProductVariantCreated called");
await this.client.uploadProductVariant({
configuration: this.providerConfig,
variant: productVariant,
});
}
async onProductVariantDeleted(productVariant: WebhookProductVariantFragment): Promise<void> {
this.logger.trace("onProductVariantDeleted called");
await this.client.deleteProductVariant({
configuration: this.providerConfig,
variant: productVariant,
@ -39,6 +48,8 @@ export class DatocmsWebhooksProcessor implements ProductWebhooksProcessor {
}
async onProductUpdated(product: WebhookProductFragment): Promise<void> {
this.logger.trace("onProductUpdated called");
await Promise.all(
(product.variants ?? []).map((variant) => {
return this.client.upsertProduct({

View file

@ -4,6 +4,7 @@ import { protectedClientProcedure } from "../../trpc/protected-client-procedure"
import { router } from "../../trpc/trpc-server";
import { DatoCMSClient } from "./datocms-client";
import { TRPCError } from "@trpc/server";
/**
* Operations specific for Datocms service.
@ -22,7 +23,11 @@ export const datocmsRouter = router({
apiToken: input.apiToken,
});
return client.getContentTypes();
return client.getContentTypes().catch((err) => {
throw new TRPCError({
code: "BAD_REQUEST",
});
});
}),
fetchContentTypeFields: protectedClientProcedure
@ -37,8 +42,14 @@ export const datocmsRouter = router({
apiToken: input.apiToken,
});
return client.getFieldsForContentType({
return client
.getFieldsForContentType({
itemTypeID: input.contentTypeID,
})
.catch((err) => {
throw new TRPCError({
code: "BAD_REQUEST",
});
});
}),
});

View file

@ -0,0 +1,50 @@
import { describe, it, expect } from "vitest";
import { FieldsMapper } from "./fields-mapper";
describe("FieldsMapper", () => {
it("Maps variant fields to configuration fields", () => {
const mappedFields = FieldsMapper.mapProductVariantToConfigurationFields({
configMapping: {
channels: "channels",
productId: "product-id",
productName: "product-name",
productSlug: "product-slug",
variantId: "variant-id",
variantName: "variant-name",
},
variant: {
id: "aaa-bbb-ccc",
name: "43",
channelListings: [
{
channel: {
id: "12345",
slug: "default-channel",
},
},
],
product: {
id: "aaa-bbb-123",
name: "Shoes",
slug: "shoes",
},
},
});
expect(mappedFields).toEqual({
"variant-name": "43",
"product-id": "aaa-bbb-123",
"product-name": "Shoes",
"product-slug": "shoes",
"variant-id": "aaa-bbb-ccc",
channels: [
{
channel: {
id: "12345",
slug: "default-channel",
},
},
],
});
});
});

View file

@ -0,0 +1,23 @@
import { WebhookProductVariantFragment } from "../../../generated/graphql";
import { ProvidersConfig } from "../configuration";
export class FieldsMapper {
static mapProductVariantToConfigurationFields({
configMapping,
variant,
}: {
variant: WebhookProductVariantFragment;
configMapping: ProvidersConfig.AnyFullShape["productVariantFieldsMapping"];
}) {
const { channels, variantName, productId, productName, productSlug, variantId } = configMapping;
return {
[variantName]: variant.name,
[productId]: variant.product.id,
[productName]: variant.product.name,
[productSlug]: variant.product.slug,
[variantId]: variant.id,
[channels]: variant.channelListings,
};
}
}

View file

@ -3,6 +3,7 @@ import { StrapiProviderConfig } from "@/modules/configuration";
import { WebhookProductVariantFragment } from "../../../../generated/graphql";
import { z } from "zod";
import { createLogger } from "@saleor/apps-shared";
import { FieldsMapper } from "../fields-mapper";
// partial response
const strapiFindOperationResult = z.object({
@ -80,21 +81,12 @@ export class StrapiClient {
}) {
this.logger.trace({ variantId: variant.id }, "Will upload product variant");
try {
const result = await this.client.create(configuration.itemType, {
// todo extract to common mapping function
[configuration.productVariantFieldsMapping.variantName]: variant.name,
[configuration.productVariantFieldsMapping.variantId]: variant.id,
[configuration.productVariantFieldsMapping.productName]: variant.product.name,
[configuration.productVariantFieldsMapping.productId]: variant.product.id,
[configuration.productVariantFieldsMapping.channels]: variant.channelListings,
[configuration.productVariantFieldsMapping.productSlug]: variant.product.slug,
const mappedFields = FieldsMapper.mapProductVariantToConfigurationFields({
variant,
configMapping: configuration.productVariantFieldsMapping,
});
return result;
} catch (e) {
console.error(e);
}
return this.client.create(configuration.itemType, mappedFields);
}
async updateProduct({
@ -120,22 +112,16 @@ export class StrapiClient {
this.logger.trace({ strapiProductIdsToUpdate }, "Will try to update strapi products");
try {
const mappedFields = FieldsMapper.mapProductVariantToConfigurationFields({
variant,
configMapping: configuration.productVariantFieldsMapping,
});
return Promise.all(
strapiProductIdsToUpdate.map((strapiProductId) => {
return this.client.update(configuration.itemType, strapiProductId, {
[configuration.productVariantFieldsMapping.variantName]: variant.name,
[configuration.productVariantFieldsMapping.variantId]: variant.id,
[configuration.productVariantFieldsMapping.productName]: variant.product.name,
[configuration.productVariantFieldsMapping.productId]: variant.product.id,
[configuration.productVariantFieldsMapping.channels]: variant.channelListings,
[configuration.productVariantFieldsMapping.productSlug]: variant.product.slug,
});
return this.client.update(configuration.itemType, strapiProductId, mappedFields);
})
);
} catch (e) {
console.error(e);
}
}
async upsertProduct({

View file

@ -8,6 +8,7 @@ import { SaleorProviderFieldsMappingKeys, StrapiProviderConfig } from "../../con
import { printSaleorProductFields } from "../../configuration/print-saleor-product-fields";
import { trpcClient } from "../../trpc/trpc-client";
import { ButtonsBox } from "../../ui/buttons-box";
import { Skeleton } from "@/modules/ui/skeleton";
type FormShape = Omit<StrapiProviderConfig.InputShape, "type">;
@ -18,14 +19,7 @@ type PureFormProps = {
};
const PureForm = ({ defaultValues, onSubmit, onDelete }: PureFormProps) => {
const {
control,
getValues,
setValue,
watch,
handleSubmit,
formState: { errors },
} = useForm<FormShape>({
const { control, handleSubmit } = useForm({
defaultValues: defaultValues,
resolver: zodResolver(StrapiProviderConfig.Schema.Input.omit({ type: true })),
});
@ -137,7 +131,7 @@ const AddFormVariant = () => {
const { notifySuccess } = useDashboardNotification();
const { mutate: addProvider } = trpcClient.providersConfigs.addOne.useMutation({
onSuccess() {
notifySuccess("Success", "Updated configuration");
notifySuccess("Success", "Saved configuration");
push("/configuration");
},
});
@ -195,7 +189,7 @@ const EditFormVariant = (props: { configId: string }) => {
});
if (!data) {
return null;
return <Skeleton.Section />;
}
if (data.type !== "strapi") {

View file

@ -1,3 +1,4 @@
import { createLogger } from "@saleor/apps-shared";
import {
WebhookProductFragment,
WebhookProductVariantFragment,
@ -8,22 +9,31 @@ import { StrapiClient } from "./strapi-client";
export class StrapiWebhooksProcessor implements ProductWebhooksProcessor {
private client: StrapiClient;
private logger = createLogger({ name: "StrapiWebhooksProcessor" });
constructor(private config: StrapiProviderConfig.FullShape) {
this.client = new StrapiClient({ url: config.url, token: config.authToken });
}
async onProductVariantUpdated(productVariant: WebhookProductVariantFragment): Promise<void> {
this.logger.trace("onProductVariantUpdated called");
this.client.updateProduct({ configuration: this.config, variant: productVariant });
}
async onProductVariantCreated(productVariant: WebhookProductVariantFragment): Promise<void> {
this.logger.trace("onProductVariantCreated called");
this.client.uploadProduct({ configuration: this.config, variant: productVariant });
}
async onProductVariantDeleted(productVariant: WebhookProductVariantFragment): Promise<void> {
this.logger.trace("onProductVariantDeleted called");
this.client.deleteProduct({ configuration: this.config, variant: productVariant });
}
async onProductUpdated(product: WebhookProductFragment): Promise<void> {
this.logger.trace("onProductUpdated called");
await Promise.all(
(product.variants ?? []).map((variant) => {
return this.client.upsertProduct({

View file

@ -0,0 +1,29 @@
import { Box, BoxProps } from "@saleor/macaw-ui/next";
// TODO: Make it more generic, move to shared or contribute to macaw
const Section = (props: BoxProps) => {
return (
<Box display="grid" gap={2} {...props}>
<Box
__height="10px"
backgroundColor="surfaceNeutralHighlight"
borderRadius={2}
__width="50%"
/>
<Box
__height="10px"
backgroundColor="surfaceNeutralHighlight"
borderRadius={2}
__width="70%"
/>
<Box
__height="10px"
backgroundColor="surfaceNeutralHighlight"
borderRadius={2}
__width="60%"
/>
</Box>
);
};
export const Skeleton = { Section };

View file

@ -80,6 +80,8 @@ export class WebhooksProcessorsDelegator {
}
async delegateVariantUpdatedOperations(productVariant: WebhookProductVariantFragment) {
this.logger.trace("delegateVariantUpdatedOperations called");
const { connections } = this.opts.context;
const relatedVariantChannels = this.extractChannelSlugsFromProductVariant(productVariant);
@ -91,6 +93,11 @@ export class WebhooksProcessorsDelegator {
relatedVariantChannels.includes(conn.channelSlug)
);
this.logger.trace(
{ connections: connectionsToInclude.length },
"Resolved a number of connections to include"
);
const processors = this.mapConnectionsToProcessors(connectionsToInclude);
return Promise.all(
@ -101,8 +108,15 @@ export class WebhooksProcessorsDelegator {
}
async delegateVariantDeletedOperations(productVariant: WebhookProductVariantFragment) {
this.logger.trace("delegateVariantDeletedOperations called");
const { connections } = this.opts.context;
this.logger.trace(
{ connections: connections.length },
"Resolved a number of connections to include"
);
const processors = this.mapConnectionsToProcessors(connections);
return Promise.all(
@ -113,8 +127,15 @@ export class WebhooksProcessorsDelegator {
}
async delegateProductUpdatedOperations(product: WebhookProductFragment) {
this.logger.trace("delegateProductUpdatedOperations called");
const { connections } = this.opts.context;
this.logger.trace(
{ connections: connections.length },
"Resolved a number of connections to include"
);
const processors = this.mapConnectionsToProcessors(connections);
return Promise.all(

View file

@ -4,6 +4,7 @@ import { NextPage } from "next";
import { useRouter } from "next/router";
import { z } from "zod";
import { Text } from "@saleor/macaw-ui/next";
import { Skeleton } from "@/modules/ui/skeleton";
const BulkSyncPage: NextPage = () => {
const { query } = useRouter();
@ -44,7 +45,7 @@ const BulkSyncPage: NextPage = () => {
}
if (connectionLoading || providerLoading) {
return <Text>Loading...</Text>;
return <Skeleton.Section />;
}
if (!(provider && connection)) {

View file

@ -37,15 +37,11 @@ const ConfigurationPage: NextPage = () => {
heading="Initial sync"
sideContent={
<Box>
<Text as="p">
<Text as="p" marginBottom={2}>
Perform an initial scan of product variants to push existing database to selected
provider.
</Text>
<Text as="p">Its recommended to run this flow initially, once app is configured.</Text>
<Text as="p" color="iconCriticalDefault">
Warning: removing products in Saleor will not remove them in CMS. To create a fully
updated.
</Text>
</Box>
}
mainContent={<BulkSyncSection />}

View file

@ -3,6 +3,7 @@ import { ProvidersResolver } from "@/modules/providers/providers-resolver";
import { trpcClient } from "@/modules/trpc/trpc-client";
import { AppHeader } from "@/modules/ui/app-header";
import { AppSection } from "@/modules/ui/app-section";
import { Skeleton } from "@/modules/ui/skeleton";
import { Breadcrumbs } from "@saleor/apps-ui";
import { Box, Text } from "@saleor/macaw-ui/next";
import { NextPage } from "next";
@ -27,7 +28,7 @@ const EditProviderPage: NextPage = () => {
}, [data]);
if (isLoading) {
return <Text>Loading...</Text>;
return <Skeleton.Section />;
}
if (isFetched && !data) {
@ -37,7 +38,7 @@ const EditProviderPage: NextPage = () => {
}
if (!provider) {
return null;
return <Skeleton.Section />;
}
const EditForm = ProvidersResolver.getEditProviderFormComponent(provider.type);