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,28 +15,42 @@ 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"
id={AddConnectionFormID}
display="grid"
__gridTemplateColumns="1fr auto 1fr"
gap={4}
alignItems="center"
>
<Select <Select
required required
size="small" size="small"
@ -60,5 +75,11 @@ export const AddConnectionForm = (props: {
}))} }))}
/> />
</Box> </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, 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) {
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}> <Modal onClose={props.onClose}>
<Text as="h2" variant="heading"> <Text as="h2" variant="heading">
Connect channel with Provider 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 Once connected, operations on product variants on this channel will be sent to selected CMS
platform. platform.
</Text> </Text>
<AddConnectionForm onSubmit={props.onSubmit} defaultValues={defaultValues} /> <AddConnectionForm onSubmit={handleFormSubmit} defaultValues={defaultValues} />
<ButtonsBox marginTop={8}> <ButtonsBox marginTop={8}>
<Button <Button
disabled={isLoading}
variant="tertiary" variant="tertiary"
onClick={() => { onClick={() => {
props.onClose(); props.onClose();
@ -36,4 +63,5 @@ export const AddConnectionModal = (props: {
</Button> </Button>
</ButtonsBox> </ButtonsBox>
</Modal> </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();
try {
config.connections.addConnection(input); 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
.getFieldsForContentType({
itemTypeID: input.contentTypeID, 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({
variant,
configMapping: configuration.productVariantFieldsMapping,
});
return Promise.all( return Promise.all(
strapiProductIdsToUpdate.map((strapiProductId) => { strapiProductIdsToUpdate.map((strapiProductId) => {
return this.client.update(configuration.itemType, strapiProductId, { return this.client.update(configuration.itemType, strapiProductId, mappedFields);
[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,
});
}) })
); );
} 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);