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 { z } from "zod";
import { ButtonsBox } from "../ui/buttons-box"; import { ButtonsBox } from "../ui/buttons-box";
import { ProvidersResolver } from "../providers/providers-resolver"; import { ProvidersResolver } from "../providers/providers-resolver";
import { Skeleton } from "../ui/skeleton";
const FormSchema = z.object({ const FormSchema = z.object({
connectionId: z.string().min(7), connectionId: z.string().min(7),
@ -33,7 +34,7 @@ export const BulkSyncSection = () => {
const { data: connections } = trpcClient.channelsProvidersConnection.fetchConnections.useQuery(); const { data: connections } = trpcClient.channelsProvidersConnection.fetchConnections.useQuery();
const { data: providers } = trpcClient.providersConfigs.getAll.useQuery(); const { data: providers } = trpcClient.providersConfigs.getAll.useQuery();
const { control, handleSubmit } = useForm<z.infer<typeof FormSchema>>({ const { control, handleSubmit } = useForm({
defaultValues: { defaultValues: {
connectionId: "", connectionId: "",
}, },
@ -41,7 +42,7 @@ export const BulkSyncSection = () => {
}); });
if (!connections || !providers) { if (!connections || !providers) {
return <Text>Loading...</Text>; return <Skeleton.Section />;
} }
if (connections.length === 0) { 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 { useFetchAllProducts } from "./use-fetch-all-products";
import { VariantsSyncStatusList } from "./variants-sync-status-list"; import { VariantsSyncStatusList } from "./variants-sync-status-list";
import { ProvidersResolver } from "../providers/providers-resolver"; import { ProvidersResolver } from "../providers/providers-resolver";
import { useDashboardNotification } from "@saleor/apps-shared";
const FetchProductsStep = (props: { onButtonClick(): void }) => { const FetchProductsStep = (props: { onButtonClick(): void }) => {
return ( return (
@ -61,13 +62,21 @@ export const BulkSyncView = ({
}) => { }) => {
const processor = useRef(ProvidersResolver.createBulkSyncProcessor(configuration)); const processor = useRef(ProvidersResolver.createBulkSyncProcessor(configuration));
const [state, setState] = useState<Status>("initial"); const [state, setState] = useState<Status>("initial");
const { notifySuccess } = useDashboardNotification();
const { products, finished: saleorProductsFetchFinished } = useFetchAllProducts( const { products, finished: saleorProductsFetchFinished } = useFetchAllProducts(
state === "fetching", state === "fetching",
connection.channelSlug 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(() => { useEffect(() => {
if (!saleorProductsFetchFinished) { if (!saleorProductsFetchFinished) {
@ -91,7 +100,7 @@ export const BulkSyncView = ({
setItemStatus(variantId, "success"); setItemStatus(variantId, "success");
}, },
onUploadError({ error, variantId }) { onUploadError({ error, variantId }) {
// todo handle error // User will be notified about the error in the UI
setItemStatus(variantId, "error"); 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 { BulkImportProductFragment } from "../../../generated/graphql";
import { VariantsSyncStatusListItem } from "./variants-sync-status-list"; import { VariantsSyncStatusListItem } from "./variants-sync-status-list";
export const useBulkSyncProductsState = () => { export const useBulkSyncProductsState = () => {
const [finished, setFinished] = useState(false);
const [productsStatusList, setProductsStatusList] = useState<VariantsSyncStatusListItem[] | null>( const [productsStatusList, setProductsStatusList] = useState<VariantsSyncStatusListItem[] | null>(
null null
); );
useEffect(() => {
if (productsStatusList?.every((item) => item.status === "success" || item.status === "error")) {
setFinished(true);
}
}, [productsStatusList]);
return { return {
finished,
productsStatusList, productsStatusList,
setInitialProducts: useCallback((products: BulkImportProductFragment[]) => { setInitialProducts: useCallback((products: BulkImportProductFragment[]) => {
setProductsStatusList( setProductsStatusList(

View file

@ -1,9 +1,10 @@
import { zodResolver } from "@hookform/resolvers/zod"; 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 { Select } from "@saleor/react-hook-form-macaw";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { ChannelProviderConnectionConfig } from "../configuration"; import { ChannelProviderConnectionConfig } from "../configuration";
import { trpcClient } from "../trpc/trpc-client"; import { trpcClient } from "../trpc/trpc-client";
import { TRPCError } from "@trpc/server";
export type AddConnectionFormSchema = Omit< export type AddConnectionFormSchema = Omit<
ChannelProviderConnectionConfig.InputShape, ChannelProviderConnectionConfig.InputShape,
@ -14,51 +15,71 @@ export const AddConnectionFormID = "new-connection-form";
export const AddConnectionForm = (props: { export const AddConnectionForm = (props: {
defaultValues: AddConnectionFormSchema; defaultValues: AddConnectionFormSchema;
onSubmit(values: AddConnectionFormSchema): void; onSubmit(values: AddConnectionFormSchema): Promise<void>;
}) => { }) => {
const { data: channels } = trpcClient.channelsProvidersConnection.fetchAllChannels.useQuery(); const { data: channels } = trpcClient.channelsProvidersConnection.fetchAllChannels.useQuery();
const { data: providers } = trpcClient.providersConfigs.getAll.useQuery(); const { data: providers } = trpcClient.providersConfigs.getAll.useQuery();
const { handleSubmit, control } = useForm<AddConnectionFormSchema>({ const {
handleSubmit,
control,
setError,
formState: { errors },
} = useForm({
defaultValues: props.defaultValues, defaultValues: props.defaultValues,
resolver: zodResolver( resolver: zodResolver(
ChannelProviderConnectionConfig.Schema.Input.omit({ providerType: true }) 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 ( return (
<Box <Box onSubmit={handleSubmit(onSubmit)} as="form" id={AddConnectionFormID}>
onSubmit={handleSubmit(props.onSubmit)} <Box display="grid" __gridTemplateColumns="1fr auto 1fr" gap={4} alignItems="center">
as="form" <Select
id={AddConnectionFormID} required
display="grid" size="small"
__gridTemplateColumns="1fr auto 1fr" control={control}
gap={4} name="channelSlug"
alignItems="center" label="Channel"
> options={channels?.map((c) => ({
<Select value: c.slug,
required label: c.name,
size="small" }))}
control={control} />
name="channelSlug" <ArrowRightIcon />
label="Channel" <Select
options={channels?.map((c) => ({ required
value: c.slug, size="small"
label: c.name, control={control}
}))} name="providerId"
/> label="Provider"
<ArrowRightIcon /> options={providers?.map((p) => ({
<Select value: p.id,
required label: p.configName,
size="small" }))}
control={control} />
name="providerId" </Box>
label="Provider" {errors.root?.serverError && (
options={providers?.map((p) => ({ <Text as="p" marginTop={2} color="textCriticalSubdued">
value: p.id, {errors.root?.serverError.message}
label: p.configName, </Text>
}))} )}
/>
</Box> </Box>
); );
}; };

View file

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

View file

@ -5,6 +5,7 @@ import { protectedClientProcedure } from "@/modules/trpc/protected-client-proced
import { router } from "@/modules/trpc/trpc-server"; import { router } from "@/modules/trpc/trpc-server";
import { z } from "zod"; import { z } from "zod";
import { FetchChannelsDocument } from "../../../generated/graphql"; import { FetchChannelsDocument } from "../../../generated/graphql";
import { TRPCError } from "@trpc/server";
const procedure = protectedClientProcedure.use(({ ctx, next }) => { const procedure = protectedClientProcedure.use(({ ctx, next }) => {
const settingsManager = createSettingsManager(ctx.apiClient, ctx.appId!); const settingsManager = createSettingsManager(ctx.apiClient, ctx.appId!);
@ -33,7 +34,24 @@ export const channelProviderConnectionRouter = router({
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const config = await ctx.appConfigService.get(); const config = await ctx.appConfigService.get();
config.connections.addConnection(input); 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); ctx.appConfigService.set(config);
}), }),

View file

@ -7,6 +7,7 @@ import { AddConnectionFormSchema } from "./add-connection-form";
import { AddConnectionModal } from "./add-connection-modal"; import { AddConnectionModal } from "./add-connection-modal";
import { ChanelProviderConnectionsSectionHeader } from "./channel-provider-connections-section-header"; import { ChanelProviderConnectionsSectionHeader } from "./channel-provider-connections-section-header";
import { ConnectionsList } from "./connections-list"; import { ConnectionsList } from "./connections-list";
import { Skeleton } from "../ui/skeleton";
const NoConnections = (props: { onCreate(): void; enabled: boolean }) => ( const NoConnections = (props: { onCreate(): void; enabled: boolean }) => (
<Box> <Box>
@ -27,12 +28,13 @@ const NoConnections = (props: { onCreate(): void; enabled: boolean }) => (
export const ChannelProviderConnectionList = () => { export const ChannelProviderConnectionList = () => {
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const { data: connectionsData, refetch } = const { data: connectionsData, refetch: refetchConnections } =
trpcClient.channelsProvidersConnection.fetchConnections.useQuery(); trpcClient.channelsProvidersConnection.fetchConnections.useQuery();
const { mutate: removeConnection } = const { mutate: removeConnection } =
trpcClient.channelsProvidersConnection.removeConnection.useMutation({ trpcClient.channelsProvidersConnection.removeConnection.useMutation({
onSuccess() { onSuccess() {
refetch(); refetchConnections();
notifySuccess("Success", "Removed connection"); notifySuccess("Success", "Removed connection");
}, },
}); });
@ -42,32 +44,10 @@ export const ChannelProviderConnectionList = () => {
trpcClient.channelsProvidersConnection.fetchAllChannels.useQuery(); trpcClient.channelsProvidersConnection.fetchAllChannels.useQuery();
const { data: providers } = trpcClient.providersConfigs.getAll.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) { 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) => { const handleDelete = (connectionId: string) => {
removeConnection({ id: connectionId }); removeConnection({ id: connectionId });
}; };
@ -83,7 +63,11 @@ export const ChannelProviderConnectionList = () => {
onClose={() => { onClose={() => {
setDialogOpen(false); setDialogOpen(false);
}} }}
onSubmit={handleFormSubmit} onSuccess={() => {
refetchConnections();
notifySuccess("Success", "Connection created");
setDialogOpen(false);
}}
/> />
)} )}
{connectionsData.length === 0 && ( {connectionsData.length === 0 && (

View file

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

View file

@ -3,6 +3,11 @@ import { generateId } from "../shared/generate-id";
import { ChannelProviderConnectionConfig } from "./schemas/channel-provider-connection.schema"; import { ChannelProviderConnectionConfig } from "./schemas/channel-provider-connection.schema";
import { ProvidersConfig, RootConfig } from "./schemas/root-config.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 * TODO
* - test * - test
@ -89,11 +94,23 @@ export class AppConfig {
addConnection: (input: ChannelProviderConnectionConfig.InputShape) => { addConnection: (input: ChannelProviderConnectionConfig.InputShape) => {
if (!this.providers.checkProviderExists(input.providerId)) { 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); 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({ this.rootData.connections.push({
...parsed, ...parsed,
id: generateId(), id: generateId(),
@ -112,4 +129,20 @@ export class AppConfig {
return this.connections.getConnections().find((c) => c.id === id); 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({ const InputSchema = z.object({
type: z.literal(Contentful.type), type: z.literal(Contentful.type),
authToken: z.string(), authToken: z.string().min(1),
spaceId: z.string(), spaceId: z.string().min(1),
environment: z.string(), environment: z.string().min(1),
configName: z.string(), configName: z.string().min(1),
contentId: z.string(), contentId: z.string().min(1),
productVariantFieldsMapping: SaleorProviderFieldsMappingSchema, productVariantFieldsMapping: SaleorProviderFieldsMappingSchema,
}); });

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -17,7 +17,6 @@ type FormSchema = Omit<ContentfulProviderConfig.InputShape, "type">;
/** /**
* TODO - when space, token or env changes, refetch queries * TODO - when space, token or env changes, refetch queries
* TODO - error handling
* TODO - refactor smaller hooks * TODO - refactor smaller hooks
*/ */
const PureForm = ({ const PureForm = ({
@ -29,14 +28,9 @@ const PureForm = ({
onSubmit(values: FormSchema): void; onSubmit(values: FormSchema): void;
onDelete?(): void; onDelete?(): void;
}) => { }) => {
const { const { notifyError } = useDashboardNotification();
control,
getValues, const { control, getValues, setValue, watch, handleSubmit, setError, clearErrors } = useForm({
setValue,
watch,
handleSubmit,
formState: { errors },
} = useForm<FormSchema>({
defaultValues: defaultValues, defaultValues: defaultValues,
resolver: zodResolver(ContentfulProviderConfig.Schema.Input.omit({ type: true })), resolver: zodResolver(ContentfulProviderConfig.Schema.Input.omit({ type: true })),
}); });
@ -45,6 +39,22 @@ const PureForm = ({
trpcClient.contentful.fetchContentTypesFromApi.useMutation({ trpcClient.contentful.fetchContentTypesFromApi.useMutation({
onSuccess(data) { onSuccess(data) {
setValue("contentId", data.items[0].sys.id ?? null); 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({ trpcClient.contentful.fetchEnvironmentsFromApi.useMutation({
onSuccess(data) { onSuccess(data) {
setValue("environment", data.items[0].sys.id); 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({ const { mutate } = trpcClient.providersConfigs.addOne.useMutation({
onSuccess() { onSuccess() {
notifySuccess("Success", "Updated configuration"); notifySuccess("Success", "Saved configuration");
push("/configuration"); push("/configuration");
}, },
}); });

View file

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

View file

@ -7,6 +7,8 @@ import { protectedClientProcedure } from "../../trpc/protected-client-procedure"
import { router } from "../../trpc/trpc-server"; import { router } from "../../trpc/trpc-server";
import { ContentfulClient } from "./contentful-client"; import { ContentfulClient } from "./contentful-client";
import { createFlatProxy } from "@trpc/server/shared";
import { createLogger } from "@saleor/apps-shared";
const procedure = protectedClientProcedure.use(({ ctx, next }) => { const procedure = protectedClientProcedure.use(({ ctx, next }) => {
const settingsManager = createSettingsManager(ctx.apiClient, ctx.appId!); const settingsManager = createSettingsManager(ctx.apiClient, ctx.appId!);
@ -15,6 +17,7 @@ const procedure = protectedClientProcedure.use(({ ctx, next }) => {
ctx: { ctx: {
settingsManager, settingsManager,
appConfigService: new AppConfigMetadataManager(settingsManager), appConfigService: new AppConfigMetadataManager(settingsManager),
logger: createLogger({ name: "contentfulRouter" }),
}, },
}); });
}); });
@ -38,7 +41,11 @@ export const contentfulRouter = router({
space: input.contentfulSpace, 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 fetchContentTypesFromApi: procedure
.input( .input(
@ -55,7 +62,7 @@ export const contentfulRouter = router({
}); });
return client.getContentTypes(input.contentfulEnv).catch((e) => { return client.getContentTypes(input.contentfulEnv).catch((e) => {
console.error(e); ctx.logger.error("Failed to fetch content types");
throw new TRPCError({ code: "BAD_REQUEST" }); 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 { BulkSyncProcessor, BulkSyncProcessorHooks } from "../../bulk-sync/bulk-sync-processor";
import { DatoCMSClient } from "./datocms-client"; import { DatoCMSClient } from "./datocms-client";
import { createLogger } from "@saleor/apps-shared";
export class DatocmsBulkSyncProcessor implements BulkSyncProcessor { export class DatocmsBulkSyncProcessor implements BulkSyncProcessor {
constructor(private config: DatocmsProviderConfig.FullShape) {} constructor(private config: DatocmsProviderConfig.FullShape) {}

View file

@ -5,6 +5,7 @@ import { z } from "zod";
import * as Sentry from "@sentry/nextjs"; import * as Sentry from "@sentry/nextjs";
import { DatocmsProviderConfig } from "@/modules/configuration/schemas/datocms-provider.schema"; import { DatocmsProviderConfig } from "@/modules/configuration/schemas/datocms-provider.schema";
import { FieldsMapper } from "../fields-mapper";
type Context = { type Context = {
configuration: DatocmsProviderConfig.FullShape; configuration: DatocmsProviderConfig.FullShape;
@ -61,16 +62,21 @@ export class DatoCMSClient {
configuration, configuration,
variant, variant,
}: Context): SimpleSchemaTypes.ItemCreateSchema { }: 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 { return {
item_type: { type: "item_type", id: configuration.itemType }, item_type: { type: "item_type", id: configuration.itemType },
[fieldsMap.variantName]: variant.name, ...fields,
[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),
}; };
} }

View file

@ -19,12 +19,52 @@ type PureFormProps = {
onDelete?(): void; 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 } = 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 } = 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(() => { const contentTypesSelectOptions = useMemo(() => {
if (!contentTypesData) { if (!contentTypesData) {
@ -37,40 +77,6 @@ const useDatoCmsRemoteFields = () => {
})); }));
}, [contentTypesData]); }, [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"); const selectedContentType = watch("itemType");
useEffect(() => { useEffect(() => {
@ -225,7 +231,7 @@ const AddFormVariant = () => {
const { notifySuccess } = useDashboardNotification(); const { notifySuccess } = useDashboardNotification();
const { mutate: addProvider } = trpcClient.providersConfigs.addOne.useMutation({ const { mutate: addProvider } = trpcClient.providersConfigs.addOne.useMutation({
onSuccess() { onSuccess() {
notifySuccess("Success", "Updated configuration"); notifySuccess("Success", "Saved configuration");
push("/configuration"); push("/configuration");
}, },
}); });

View file

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

View file

@ -4,6 +4,7 @@ import { protectedClientProcedure } from "../../trpc/protected-client-procedure"
import { router } from "../../trpc/trpc-server"; import { router } from "../../trpc/trpc-server";
import { DatoCMSClient } from "./datocms-client"; import { DatoCMSClient } from "./datocms-client";
import { TRPCError } from "@trpc/server";
/** /**
* Operations specific for Datocms service. * Operations specific for Datocms service.
@ -22,7 +23,11 @@ export const datocmsRouter = router({
apiToken: input.apiToken, apiToken: input.apiToken,
}); });
return client.getContentTypes(); return client.getContentTypes().catch((err) => {
throw new TRPCError({
code: "BAD_REQUEST",
});
});
}), }),
fetchContentTypeFields: protectedClientProcedure fetchContentTypeFields: protectedClientProcedure
@ -37,8 +42,14 @@ export const datocmsRouter = router({
apiToken: input.apiToken, apiToken: input.apiToken,
}); });
return client.getFieldsForContentType({ return client
itemTypeID: input.contentTypeID, .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 { WebhookProductVariantFragment } from "../../../../generated/graphql";
import { z } from "zod"; import { z } from "zod";
import { createLogger } from "@saleor/apps-shared"; import { createLogger } from "@saleor/apps-shared";
import { FieldsMapper } from "../fields-mapper";
// partial response // partial response
const strapiFindOperationResult = z.object({ const strapiFindOperationResult = z.object({
@ -80,21 +81,12 @@ export class StrapiClient {
}) { }) {
this.logger.trace({ variantId: variant.id }, "Will upload product variant"); this.logger.trace({ variantId: variant.id }, "Will upload product variant");
try { const mappedFields = FieldsMapper.mapProductVariantToConfigurationFields({
const result = await this.client.create(configuration.itemType, { variant,
// todo extract to common mapping function configMapping: configuration.productVariantFieldsMapping,
[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 result; return this.client.create(configuration.itemType, mappedFields);
} catch (e) {
console.error(e);
}
} }
async updateProduct({ async updateProduct({
@ -120,22 +112,16 @@ export class StrapiClient {
this.logger.trace({ strapiProductIdsToUpdate }, "Will try to update strapi products"); this.logger.trace({ strapiProductIdsToUpdate }, "Will try to update strapi products");
try { const mappedFields = FieldsMapper.mapProductVariantToConfigurationFields({
return Promise.all( variant,
strapiProductIdsToUpdate.map((strapiProductId) => { configMapping: configuration.productVariantFieldsMapping,
return this.client.update(configuration.itemType, strapiProductId, { });
[configuration.productVariantFieldsMapping.variantName]: variant.name,
[configuration.productVariantFieldsMapping.variantId]: variant.id, return Promise.all(
[configuration.productVariantFieldsMapping.productName]: variant.product.name, strapiProductIdsToUpdate.map((strapiProductId) => {
[configuration.productVariantFieldsMapping.productId]: variant.product.id, return this.client.update(configuration.itemType, strapiProductId, mappedFields);
[configuration.productVariantFieldsMapping.channels]: variant.channelListings, })
[configuration.productVariantFieldsMapping.productSlug]: variant.product.slug, );
});
})
);
} catch (e) {
console.error(e);
}
} }
async upsertProduct({ async upsertProduct({

View file

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

View file

@ -1,3 +1,4 @@
import { createLogger } from "@saleor/apps-shared";
import { import {
WebhookProductFragment, WebhookProductFragment,
WebhookProductVariantFragment, WebhookProductVariantFragment,
@ -8,22 +9,31 @@ import { StrapiClient } from "./strapi-client";
export class StrapiWebhooksProcessor implements ProductWebhooksProcessor { export class StrapiWebhooksProcessor implements ProductWebhooksProcessor {
private client: StrapiClient; private client: StrapiClient;
private logger = createLogger({ name: "StrapiWebhooksProcessor" });
constructor(private config: StrapiProviderConfig.FullShape) { constructor(private config: StrapiProviderConfig.FullShape) {
this.client = new StrapiClient({ url: config.url, token: config.authToken }); this.client = new StrapiClient({ url: config.url, token: config.authToken });
} }
async onProductVariantUpdated(productVariant: WebhookProductVariantFragment): Promise<void> { async onProductVariantUpdated(productVariant: WebhookProductVariantFragment): Promise<void> {
this.logger.trace("onProductVariantUpdated called");
this.client.updateProduct({ configuration: this.config, variant: productVariant }); this.client.updateProduct({ configuration: this.config, variant: productVariant });
} }
async onProductVariantCreated(productVariant: WebhookProductVariantFragment): Promise<void> { async onProductVariantCreated(productVariant: WebhookProductVariantFragment): Promise<void> {
this.logger.trace("onProductVariantCreated called");
this.client.uploadProduct({ configuration: this.config, variant: productVariant }); this.client.uploadProduct({ configuration: this.config, variant: productVariant });
} }
async onProductVariantDeleted(productVariant: WebhookProductVariantFragment): Promise<void> { async onProductVariantDeleted(productVariant: WebhookProductVariantFragment): Promise<void> {
this.logger.trace("onProductVariantDeleted called");
this.client.deleteProduct({ configuration: this.config, variant: productVariant }); this.client.deleteProduct({ configuration: this.config, variant: productVariant });
} }
async onProductUpdated(product: WebhookProductFragment): Promise<void> { async onProductUpdated(product: WebhookProductFragment): Promise<void> {
this.logger.trace("onProductUpdated called");
await Promise.all( await Promise.all(
(product.variants ?? []).map((variant) => { (product.variants ?? []).map((variant) => {
return this.client.upsertProduct({ 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) { async delegateVariantUpdatedOperations(productVariant: WebhookProductVariantFragment) {
this.logger.trace("delegateVariantUpdatedOperations called");
const { connections } = this.opts.context; const { connections } = this.opts.context;
const relatedVariantChannels = this.extractChannelSlugsFromProductVariant(productVariant); const relatedVariantChannels = this.extractChannelSlugsFromProductVariant(productVariant);
@ -91,6 +93,11 @@ export class WebhooksProcessorsDelegator {
relatedVariantChannels.includes(conn.channelSlug) relatedVariantChannels.includes(conn.channelSlug)
); );
this.logger.trace(
{ connections: connectionsToInclude.length },
"Resolved a number of connections to include"
);
const processors = this.mapConnectionsToProcessors(connectionsToInclude); const processors = this.mapConnectionsToProcessors(connectionsToInclude);
return Promise.all( return Promise.all(
@ -101,8 +108,15 @@ export class WebhooksProcessorsDelegator {
} }
async delegateVariantDeletedOperations(productVariant: WebhookProductVariantFragment) { async delegateVariantDeletedOperations(productVariant: WebhookProductVariantFragment) {
this.logger.trace("delegateVariantDeletedOperations called");
const { connections } = this.opts.context; const { connections } = this.opts.context;
this.logger.trace(
{ connections: connections.length },
"Resolved a number of connections to include"
);
const processors = this.mapConnectionsToProcessors(connections); const processors = this.mapConnectionsToProcessors(connections);
return Promise.all( return Promise.all(
@ -113,8 +127,15 @@ export class WebhooksProcessorsDelegator {
} }
async delegateProductUpdatedOperations(product: WebhookProductFragment) { async delegateProductUpdatedOperations(product: WebhookProductFragment) {
this.logger.trace("delegateProductUpdatedOperations called");
const { connections } = this.opts.context; const { connections } = this.opts.context;
this.logger.trace(
{ connections: connections.length },
"Resolved a number of connections to include"
);
const processors = this.mapConnectionsToProcessors(connections); const processors = this.mapConnectionsToProcessors(connections);
return Promise.all( return Promise.all(

View file

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

View file

@ -37,15 +37,11 @@ const ConfigurationPage: NextPage = () => {
heading="Initial sync" heading="Initial sync"
sideContent={ sideContent={
<Box> <Box>
<Text as="p"> <Text as="p" marginBottom={2}>
Perform an initial scan of product variants to push existing database to selected Perform an initial scan of product variants to push existing database to selected
provider. provider.
</Text> </Text>
<Text as="p">Its recommended to run this flow initially, once app is configured.</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> </Box>
} }
mainContent={<BulkSyncSection />} mainContent={<BulkSyncSection />}

View file

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