From 5d41af93e183feb6d630da32d653fd1a53a19811 Mon Sep 17 00:00:00 2001 From: Lukasz Ostrowski Date: Wed, 19 Jul 2023 10:22:00 +0200 Subject: [PATCH] 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 --- .changeset/chilly-donuts-allow.md | 5 + .changeset/few-hornets-argue.md | 10 ++ .changeset/three-yaks-attack.md | 5 + .../modules/bulk-sync/bulk-sync-section.tsx | 5 +- .../src/modules/bulk-sync/bulk-sync-view.tsx | 13 ++- .../bulk-sync/use-bulk-sync-products-state.ts | 10 +- .../add-connection-form.tsx | 91 ++++++++++++------- .../add-connection-modal.tsx | 84 +++++++++++------ .../channel-provider-connection.router.ts | 20 +++- .../channels-provider-connection-list.tsx | 36 ++------ .../connections-list.tsx | 3 +- .../src/modules/configuration/app-config.ts | 35 ++++++- .../schemas/contentful-provider.schema.ts | 10 +- .../schemas/datocms-provider.schema.ts | 6 +- .../schemas/root-config.schema.ts | 1 - .../schemas/strapi-provider.schema.ts | 6 +- .../providers-listing/providers-list.tsx | 3 +- .../builder.io/builder-io-config-form.tsx | 11 +-- .../providers/builder.io/builder-io.client.ts | 16 +--- .../providers/contentful/contentful-client.ts | 4 +- .../contentful/contentful-config-form.tsx | 46 ++++++++-- .../contentful-webhooks-processor.ts | 10 ++ .../providers/contentful/contentful.router.ts | 11 ++- .../datocms/datocms-bulk-sync-processor.ts | 1 + .../providers/datocms/datocms-client.ts | 20 ++-- .../providers/datocms/datocms-config-form.tsx | 82 +++++++++-------- .../datocms/datocms-webhooks-processor.ts | 11 +++ .../providers/datocms/datocms.router.ts | 19 +++- .../modules/providers/fields-mapper.test.ts | 50 ++++++++++ .../src/modules/providers/fields-mapper.ts | 23 +++++ .../modules/providers/strapi/strapi-client.ts | 46 ++++------ .../providers/strapi/strapi-config-form.tsx | 14 +-- .../strapi/strapi-webhooks-processor.ts | 10 ++ apps/cms-v2/src/modules/ui/skeleton.tsx | 29 ++++++ .../webhooks-processors-delegator.ts | 21 +++++ .../src/pages/bulk-sync/[connection-id].tsx | 3 +- apps/cms-v2/src/pages/configuration.tsx | 6 +- .../src/pages/edit-provider/[configId].tsx | 5 +- 38 files changed, 541 insertions(+), 240 deletions(-) create mode 100644 .changeset/chilly-donuts-allow.md create mode 100644 .changeset/few-hornets-argue.md create mode 100644 .changeset/three-yaks-attack.md create mode 100644 apps/cms-v2/src/modules/providers/fields-mapper.test.ts create mode 100644 apps/cms-v2/src/modules/providers/fields-mapper.ts create mode 100644 apps/cms-v2/src/modules/ui/skeleton.tsx diff --git a/.changeset/chilly-donuts-allow.md b/.changeset/chilly-donuts-allow.md new file mode 100644 index 0000000..70572f0 --- /dev/null +++ b/.changeset/chilly-donuts-allow.md @@ -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. diff --git a/.changeset/few-hornets-argue.md b/.changeset/few-hornets-argue.md new file mode 100644 index 0000000..aa75368 --- /dev/null +++ b/.changeset/few-hornets-argue.md @@ -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 \ No newline at end of file diff --git a/.changeset/three-yaks-attack.md b/.changeset/three-yaks-attack.md new file mode 100644 index 0000000..8678aa1 --- /dev/null +++ b/.changeset/three-yaks-attack.md @@ -0,0 +1,5 @@ +--- +"saleor-app-cms-v2": minor +--- + +Added skeletons instead raw "Loading" texts. Also removed unnecessary warning message in bulk sync section diff --git a/apps/cms-v2/src/modules/bulk-sync/bulk-sync-section.tsx b/apps/cms-v2/src/modules/bulk-sync/bulk-sync-section.tsx index dfeea72..2d796fc 100644 --- a/apps/cms-v2/src/modules/bulk-sync/bulk-sync-section.tsx +++ b/apps/cms-v2/src/modules/bulk-sync/bulk-sync-section.tsx @@ -8,6 +8,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { ButtonsBox } from "../ui/buttons-box"; import { ProvidersResolver } from "../providers/providers-resolver"; +import { Skeleton } from "../ui/skeleton"; const FormSchema = z.object({ connectionId: z.string().min(7), @@ -33,7 +34,7 @@ export const BulkSyncSection = () => { const { data: connections } = trpcClient.channelsProvidersConnection.fetchConnections.useQuery(); const { data: providers } = trpcClient.providersConfigs.getAll.useQuery(); - const { control, handleSubmit } = useForm>({ + const { control, handleSubmit } = useForm({ defaultValues: { connectionId: "", }, @@ -41,7 +42,7 @@ export const BulkSyncSection = () => { }); if (!connections || !providers) { - return Loading...; + return ; } if (connections.length === 0) { diff --git a/apps/cms-v2/src/modules/bulk-sync/bulk-sync-view.tsx b/apps/cms-v2/src/modules/bulk-sync/bulk-sync-view.tsx index b9d764e..9362690 100644 --- a/apps/cms-v2/src/modules/bulk-sync/bulk-sync-view.tsx +++ b/apps/cms-v2/src/modules/bulk-sync/bulk-sync-view.tsx @@ -10,6 +10,7 @@ import { useBulkSyncProductsState } from "./use-bulk-sync-products-state"; import { useFetchAllProducts } from "./use-fetch-all-products"; import { VariantsSyncStatusList } from "./variants-sync-status-list"; import { ProvidersResolver } from "../providers/providers-resolver"; +import { useDashboardNotification } from "@saleor/apps-shared"; const FetchProductsStep = (props: { onButtonClick(): void }) => { return ( @@ -61,13 +62,21 @@ export const BulkSyncView = ({ }) => { const processor = useRef(ProvidersResolver.createBulkSyncProcessor(configuration)); const [state, setState] = useState("initial"); + const { notifySuccess } = useDashboardNotification(); const { products, finished: saleorProductsFetchFinished } = useFetchAllProducts( state === "fetching", connection.channelSlug ); - const { productsStatusList, setInitialProducts, setItemStatus } = useBulkSyncProductsState(); + const { productsStatusList, setInitialProducts, setItemStatus, finished } = + useBulkSyncProductsState(); + + useEffect(() => { + if (finished) { + notifySuccess("Bulk sync ended", "All products have been synced, please verify results"); + } + }, [finished, notifySuccess]); useEffect(() => { if (!saleorProductsFetchFinished) { @@ -91,7 +100,7 @@ export const BulkSyncView = ({ setItemStatus(variantId, "success"); }, onUploadError({ error, variantId }) { - // todo handle error + // User will be notified about the error in the UI setItemStatus(variantId, "error"); }, }); diff --git a/apps/cms-v2/src/modules/bulk-sync/use-bulk-sync-products-state.ts b/apps/cms-v2/src/modules/bulk-sync/use-bulk-sync-products-state.ts index e657da4..c597dd2 100644 --- a/apps/cms-v2/src/modules/bulk-sync/use-bulk-sync-products-state.ts +++ b/apps/cms-v2/src/modules/bulk-sync/use-bulk-sync-products-state.ts @@ -1,13 +1,21 @@ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { BulkImportProductFragment } from "../../../generated/graphql"; import { VariantsSyncStatusListItem } from "./variants-sync-status-list"; export const useBulkSyncProductsState = () => { + const [finished, setFinished] = useState(false); const [productsStatusList, setProductsStatusList] = useState( null ); + useEffect(() => { + if (productsStatusList?.every((item) => item.status === "success" || item.status === "error")) { + setFinished(true); + } + }, [productsStatusList]); + return { + finished, productsStatusList, setInitialProducts: useCallback((products: BulkImportProductFragment[]) => { setProductsStatusList( diff --git a/apps/cms-v2/src/modules/channel-provider-connection/add-connection-form.tsx b/apps/cms-v2/src/modules/channel-provider-connection/add-connection-form.tsx index 8d2440d..5f31c66 100644 --- a/apps/cms-v2/src/modules/channel-provider-connection/add-connection-form.tsx +++ b/apps/cms-v2/src/modules/channel-provider-connection/add-connection-form.tsx @@ -1,9 +1,10 @@ import { zodResolver } from "@hookform/resolvers/zod"; -import { ArrowRightIcon, Box } from "@saleor/macaw-ui/next"; +import { ArrowRightIcon, Box, Text } from "@saleor/macaw-ui/next"; import { Select } from "@saleor/react-hook-form-macaw"; import { useForm } from "react-hook-form"; import { ChannelProviderConnectionConfig } from "../configuration"; import { trpcClient } from "../trpc/trpc-client"; +import { TRPCError } from "@trpc/server"; export type AddConnectionFormSchema = Omit< ChannelProviderConnectionConfig.InputShape, @@ -14,51 +15,71 @@ export const AddConnectionFormID = "new-connection-form"; export const AddConnectionForm = (props: { defaultValues: AddConnectionFormSchema; - onSubmit(values: AddConnectionFormSchema): void; + onSubmit(values: AddConnectionFormSchema): Promise; }) => { const { data: channels } = trpcClient.channelsProvidersConnection.fetchAllChannels.useQuery(); const { data: providers } = trpcClient.providersConfigs.getAll.useQuery(); - const { handleSubmit, control } = useForm({ + const { + handleSubmit, + control, + setError, + formState: { errors }, + } = useForm({ defaultValues: props.defaultValues, resolver: zodResolver( ChannelProviderConnectionConfig.Schema.Input.omit({ providerType: true }) ), }); + const onSubmit = (values: AddConnectionFormSchema) => { + props.onSubmit(values).catch((err: TRPCError) => { + setError("channelSlug", { + type: "manual", + }); + setError("providerId", { + type: "manual", + }); + + setError("root.serverError", { + type: "manual", + message: err.message, + }); + }); + }; + return ( - - ({ - value: p.id, - label: p.configName, - }))} - /> + + + ({ + value: p.id, + label: p.configName, + }))} + /> + + {errors.root?.serverError && ( + + {errors.root?.serverError.message} + + )} ); }; diff --git a/apps/cms-v2/src/modules/channel-provider-connection/add-connection-modal.tsx b/apps/cms-v2/src/modules/channel-provider-connection/add-connection-modal.tsx index 1541412..5d06717 100644 --- a/apps/cms-v2/src/modules/channel-provider-connection/add-connection-modal.tsx +++ b/apps/cms-v2/src/modules/channel-provider-connection/add-connection-modal.tsx @@ -6,34 +6,62 @@ import { AddConnectionFormID, AddConnectionFormSchema, } from "./add-connection-form"; +import { trpcClient } from "../trpc/trpc-client"; +import { Skeleton } from "../ui/skeleton"; const defaultValues: AddConnectionFormSchema = { channelSlug: "", providerId: "" }; -export const AddConnectionModal = (props: { - onSubmit(values: AddConnectionFormSchema): void; - onClose(): void; -}) => ( - - - Connect channel with Provider - - - Once connected, operations on product variants on this channel will be sent to selected CMS - platform. - - - - - - - -); +export const AddConnectionModal = (props: { onSuccess(): void; onClose(): void }) => { + const { data: providers } = trpcClient.providersConfigs.getAll.useQuery(); + + if (!providers) { + return ; + } + + 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 ( + + + Connect channel with Provider + + + Once connected, operations on product variants on this channel will be sent to selected CMS + platform. + + + + + + + + ); +}; diff --git a/apps/cms-v2/src/modules/channel-provider-connection/channel-provider-connection.router.ts b/apps/cms-v2/src/modules/channel-provider-connection/channel-provider-connection.router.ts index b9cd4b2..a2216c7 100644 --- a/apps/cms-v2/src/modules/channel-provider-connection/channel-provider-connection.router.ts +++ b/apps/cms-v2/src/modules/channel-provider-connection/channel-provider-connection.router.ts @@ -5,6 +5,7 @@ import { protectedClientProcedure } from "@/modules/trpc/protected-client-proced import { router } from "@/modules/trpc/trpc-server"; import { z } from "zod"; import { FetchChannelsDocument } from "../../../generated/graphql"; +import { TRPCError } from "@trpc/server"; const procedure = protectedClientProcedure.use(({ ctx, next }) => { const settingsManager = createSettingsManager(ctx.apiClient, ctx.appId!); @@ -33,7 +34,24 @@ export const channelProviderConnectionRouter = router({ .mutation(async ({ ctx, input }) => { const config = await ctx.appConfigService.get(); - 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); }), diff --git a/apps/cms-v2/src/modules/channel-provider-connection/channels-provider-connection-list.tsx b/apps/cms-v2/src/modules/channel-provider-connection/channels-provider-connection-list.tsx index 84c4e4d..65a8ada 100644 --- a/apps/cms-v2/src/modules/channel-provider-connection/channels-provider-connection-list.tsx +++ b/apps/cms-v2/src/modules/channel-provider-connection/channels-provider-connection-list.tsx @@ -7,6 +7,7 @@ import { AddConnectionFormSchema } from "./add-connection-form"; import { AddConnectionModal } from "./add-connection-modal"; import { ChanelProviderConnectionsSectionHeader } from "./channel-provider-connections-section-header"; import { ConnectionsList } from "./connections-list"; +import { Skeleton } from "../ui/skeleton"; const NoConnections = (props: { onCreate(): void; enabled: boolean }) => ( @@ -27,12 +28,13 @@ const NoConnections = (props: { onCreate(): void; enabled: boolean }) => ( export const ChannelProviderConnectionList = () => { const [dialogOpen, setDialogOpen] = useState(false); - const { data: connectionsData, refetch } = + const { data: connectionsData, refetch: refetchConnections } = trpcClient.channelsProvidersConnection.fetchConnections.useQuery(); + const { mutate: removeConnection } = trpcClient.channelsProvidersConnection.removeConnection.useMutation({ onSuccess() { - refetch(); + refetchConnections(); notifySuccess("Success", "Removed connection"); }, }); @@ -42,32 +44,10 @@ export const ChannelProviderConnectionList = () => { trpcClient.channelsProvidersConnection.fetchAllChannels.useQuery(); const { data: providers } = trpcClient.providersConfigs.getAll.useQuery(); - const { mutate: addProviderMutate } = - trpcClient.channelsProvidersConnection.addConnection.useMutation({ - onSuccess() { - notifySuccess("Success", "Added connection"); - refetch(); - setDialogOpen(false); - }, - }); - if (!providers) { - return null; + return ; } - const handleFormSubmit = (values: AddConnectionFormSchema) => { - const providerType = providers.find((p) => p.id === values.providerId)?.type; - - if (!providerType) { - throw new Error("Provider not found"); - } - - addProviderMutate({ - ...values, - providerType, - }); - }; - const handleDelete = (connectionId: string) => { removeConnection({ id: connectionId }); }; @@ -83,7 +63,11 @@ export const ChannelProviderConnectionList = () => { onClose={() => { setDialogOpen(false); }} - onSubmit={handleFormSubmit} + onSuccess={() => { + refetchConnections(); + notifySuccess("Success", "Connection created"); + setDialogOpen(false); + }} /> )} {connectionsData.length === 0 && ( diff --git a/apps/cms-v2/src/modules/channel-provider-connection/connections-list.tsx b/apps/cms-v2/src/modules/channel-provider-connection/connections-list.tsx index 80ffb0c..2dc76b0 100644 --- a/apps/cms-v2/src/modules/channel-provider-connection/connections-list.tsx +++ b/apps/cms-v2/src/modules/channel-provider-connection/connections-list.tsx @@ -4,6 +4,7 @@ import React from "react"; import { trpcClient } from "../trpc/trpc-client"; import { ChanelProviderConnectionsSectionHeader } from "./channel-provider-connections-section-header"; import { ProvidersResolver } from "../providers/providers-resolver"; +import { Skeleton } from "../ui/skeleton"; export const ConnectionsList = (props: { onRemove(connectionId: string): void }) => { const { data } = trpcClient.channelsProvidersConnection.fetchConnections.useQuery(); @@ -11,7 +12,7 @@ export const ConnectionsList = (props: { onRemove(connectionId: string): void }) const { data: providers } = trpcClient.providersConfigs.getAll.useQuery(); if (!data || !providers) { - return null; + return ; } return ( diff --git a/apps/cms-v2/src/modules/configuration/app-config.ts b/apps/cms-v2/src/modules/configuration/app-config.ts index 8f41869..643b70f 100644 --- a/apps/cms-v2/src/modules/configuration/app-config.ts +++ b/apps/cms-v2/src/modules/configuration/app-config.ts @@ -3,6 +3,11 @@ import { generateId } from "../shared/generate-id"; import { ChannelProviderConnectionConfig } from "./schemas/channel-provider-connection.schema"; import { ProvidersConfig, RootConfig } from "./schemas/root-config.schema"; +export const Errors = { + CONNECTION_ALREADY_EXISTS: "CONNECTION_ALREADY_EXISTS", + PROVIDER_DOESNT_EXIST: "PROVIDER_DOESNT_EXIST", +}; + /** * TODO * - test @@ -89,11 +94,23 @@ export class AppConfig { addConnection: (input: ChannelProviderConnectionConfig.InputShape) => { if (!this.providers.checkProviderExists(input.providerId)) { - throw new Error("Provider doesnt exist"); + const err = new Error("Provider doesnt exist"); + + err.cause = Errors.PROVIDER_DOESNT_EXIST; + + throw err; } const parsed = ChannelProviderConnectionConfig.Schema.Input.parse(input); + if (this.checkConnectionExists(input)) { + const error = new Error("Connection already exists"); + + error.cause = Errors.CONNECTION_ALREADY_EXISTS; + + throw error; + } + this.rootData.connections.push({ ...parsed, id: generateId(), @@ -112,4 +129,20 @@ export class AppConfig { return this.connections.getConnections().find((c) => c.id === id); }, }; + + /** + * Returns true if exists + */ + private checkConnectionExists(newConnection: ChannelProviderConnectionConfig.InputShape) { + /** + * Make array of strings so its easy to compare + */ + const existingConnectionsAsStrings = this.connections + .getConnections() + .map((connection) => `${connection.channelSlug}-${connection.providerId}`); + + const newConnectionAsString = `${newConnection.channelSlug}-${newConnection.providerId}`; + + return existingConnectionsAsStrings.includes(newConnectionAsString); + } } diff --git a/apps/cms-v2/src/modules/configuration/schemas/contentful-provider.schema.ts b/apps/cms-v2/src/modules/configuration/schemas/contentful-provider.schema.ts index ae36173..94764f7 100644 --- a/apps/cms-v2/src/modules/configuration/schemas/contentful-provider.schema.ts +++ b/apps/cms-v2/src/modules/configuration/schemas/contentful-provider.schema.ts @@ -4,11 +4,11 @@ import { SaleorProviderFieldsMappingSchema } from "./saleor-provider-fields-mapp const InputSchema = z.object({ type: z.literal(Contentful.type), - authToken: z.string(), - spaceId: z.string(), - environment: z.string(), - configName: z.string(), - contentId: z.string(), + authToken: z.string().min(1), + spaceId: z.string().min(1), + environment: z.string().min(1), + configName: z.string().min(1), + contentId: z.string().min(1), productVariantFieldsMapping: SaleorProviderFieldsMappingSchema, }); diff --git a/apps/cms-v2/src/modules/configuration/schemas/datocms-provider.schema.ts b/apps/cms-v2/src/modules/configuration/schemas/datocms-provider.schema.ts index 4da1e66..ca7bb6e 100644 --- a/apps/cms-v2/src/modules/configuration/schemas/datocms-provider.schema.ts +++ b/apps/cms-v2/src/modules/configuration/schemas/datocms-provider.schema.ts @@ -4,9 +4,9 @@ import { SaleorProviderFieldsMappingSchema } from "./saleor-provider-fields-mapp const InputSchema = z.object({ type: z.literal(Datocms.type), - authToken: z.string(), - configName: z.string(), - itemType: z.string(), + authToken: z.string().min(1), + configName: z.string().min(1), + itemType: z.string().min(1), productVariantFieldsMapping: SaleorProviderFieldsMappingSchema, }); diff --git a/apps/cms-v2/src/modules/configuration/schemas/root-config.schema.ts b/apps/cms-v2/src/modules/configuration/schemas/root-config.schema.ts index 72909ef..4ef5ff8 100644 --- a/apps/cms-v2/src/modules/configuration/schemas/root-config.schema.ts +++ b/apps/cms-v2/src/modules/configuration/schemas/root-config.schema.ts @@ -5,7 +5,6 @@ import { BuilderIoProviderConfig } from "./builder-provider.schema"; import { StrapiProviderConfig } from "./strapi-provider.schema"; import { DatocmsProviderConfig } from "./datocms-provider.schema"; -// todo move to shared bootstrap? export namespace ProvidersConfig { const AnyFull = z.union([ /** diff --git a/apps/cms-v2/src/modules/configuration/schemas/strapi-provider.schema.ts b/apps/cms-v2/src/modules/configuration/schemas/strapi-provider.schema.ts index a6f1c9a..3b36498 100644 --- a/apps/cms-v2/src/modules/configuration/schemas/strapi-provider.schema.ts +++ b/apps/cms-v2/src/modules/configuration/schemas/strapi-provider.schema.ts @@ -5,9 +5,9 @@ import { SaleorProviderFieldsMappingSchema } from "./saleor-provider-fields-mapp const InputSchema = z.object({ configName: z.string(), type: z.literal(Strapi.type), - url: z.string().url(), - authToken: z.string(), - itemType: z.string(), + url: z.string().url().min(1), + authToken: z.string().min(1), + itemType: z.string().min(1), productVariantFieldsMapping: SaleorProviderFieldsMappingSchema, }); diff --git a/apps/cms-v2/src/modules/providers-listing/providers-list.tsx b/apps/cms-v2/src/modules/providers-listing/providers-list.tsx index f8b43ef..0a0cf66 100644 --- a/apps/cms-v2/src/modules/providers-listing/providers-list.tsx +++ b/apps/cms-v2/src/modules/providers-listing/providers-list.tsx @@ -6,6 +6,7 @@ import { ProvidersConfig } from "../configuration"; import { ProvidersResolver } from "../providers/providers-resolver"; import { trpcClient } from "../trpc/trpc-client"; import { ButtonsBox } from "../ui/buttons-box"; +import { Skeleton } from "../ui/skeleton"; const ProvidersTable = (props: { providers: ProvidersConfig.AnyFullShape[] }) => { const { push } = useRouter(); @@ -46,7 +47,7 @@ export const ProvidersList = () => { const { push } = useRouter(); if (!data) { - return null; + return ; } if (data.length === 0) { diff --git a/apps/cms-v2/src/modules/providers/builder.io/builder-io-config-form.tsx b/apps/cms-v2/src/modules/providers/builder.io/builder-io-config-form.tsx index f755bdd..b4dbe1d 100644 --- a/apps/cms-v2/src/modules/providers/builder.io/builder-io-config-form.tsx +++ b/apps/cms-v2/src/modules/providers/builder.io/builder-io-config-form.tsx @@ -9,6 +9,7 @@ import { printSaleorProductFields } from "../../configuration/print-saleor-produ import { trpcClient } from "../../trpc/trpc-client"; import { ButtonsBox } from "../../ui/buttons-box"; import { TextLink } from "@saleor/apps-ui"; +import { Skeleton } from "@/modules/ui/skeleton"; type FormShape = Omit; const FormSchema = BuilderIoProviderConfig.Schema.Input.omit({ type: true }); @@ -20,7 +21,7 @@ type PureFormProps = { }; const PureForm = ({ defaultValues, onSubmit, onDelete }: PureFormProps) => { - const { control, handleSubmit } = useForm({ + const { control, handleSubmit } = useForm({ defaultValues: defaultValues, resolver: zodResolver(FormSchema), }); @@ -146,7 +147,7 @@ const AddFormVariant = () => { const { notifySuccess } = useDashboardNotification(); const { mutate: addProvider } = trpcClient.providersConfigs.addOne.useMutation({ onSuccess() { - notifySuccess("Success", "Updated configuration"); + notifySuccess("Success", "Saved configuration"); push("/configuration"); }, }); @@ -204,7 +205,7 @@ const EditFormVariant = (props: { configId: string }) => { }); if (!data) { - return null; + return ; } if (data.type !== "builder.io") { @@ -230,10 +231,6 @@ const EditFormVariant = (props: { configId: string }) => { ); }; -/* - * todo make the same with contentful - * todo improve copy - */ export const BuilderIoConfigForm = { PureVariant: PureForm, AddVariant: AddFormVariant, diff --git a/apps/cms-v2/src/modules/providers/builder.io/builder-io.client.ts b/apps/cms-v2/src/modules/providers/builder.io/builder-io.client.ts index 3edc127..2059d20 100644 --- a/apps/cms-v2/src/modules/providers/builder.io/builder-io.client.ts +++ b/apps/cms-v2/src/modules/providers/builder.io/builder-io.client.ts @@ -1,6 +1,7 @@ import { BuilderIoProviderConfig } from "@/modules/configuration"; import { WebhookProductVariantFragment } from "../../../../generated/graphql"; import { createLogger } from "@saleor/apps-shared"; +import { FieldsMapper } from "../fields-mapper"; // https://www.builder.io/c/docs/write-api export class BuilderIoClient { @@ -12,17 +13,10 @@ export class BuilderIoClient { } private mapVariantToFields(variant: WebhookProductVariantFragment) { - const { channels, productId, productName, productSlug, variantId, variantName } = - this.config.productVariantFieldsMapping; - - return { - [channels]: variant.channelListings, - [productId]: variant.product.id, - [productName]: variant.product.name, - [productSlug]: variant.product.slug, - [variantId]: variant.id, - [variantName]: variant.name, - }; + return FieldsMapper.mapProductVariantToConfigurationFields({ + variant, + configMapping: this.config.productVariantFieldsMapping, + }); } async uploadProductVariant(variant: WebhookProductVariantFragment) { diff --git a/apps/cms-v2/src/modules/providers/contentful/contentful-client.ts b/apps/cms-v2/src/modules/providers/contentful/contentful-client.ts index ee47f78..620bbc2 100644 --- a/apps/cms-v2/src/modules/providers/contentful/contentful-client.ts +++ b/apps/cms-v2/src/modules/providers/contentful/contentful-client.ts @@ -30,8 +30,6 @@ const defaultSdkClientFactory: SdkClientFactory = (opts) => /** * Wrapper facade of * https://www.npmjs.com/package/contentful - * - * TODO: tests */ export class ContentfulClient { private client: ContentfulApiClientChunk; @@ -46,7 +44,7 @@ export class ContentfulClient { } /** - * Support on en-US locale now + * Support only en-US locale now */ private mapVariantToConfiguredFields = ( variant: WebhookProductVariantFragment, diff --git a/apps/cms-v2/src/modules/providers/contentful/contentful-config-form.tsx b/apps/cms-v2/src/modules/providers/contentful/contentful-config-form.tsx index 81070f8..f925578 100644 --- a/apps/cms-v2/src/modules/providers/contentful/contentful-config-form.tsx +++ b/apps/cms-v2/src/modules/providers/contentful/contentful-config-form.tsx @@ -17,7 +17,6 @@ type FormSchema = Omit; /** * TODO - when space, token or env changes, refetch queries - * TODO - error handling * TODO - refactor smaller hooks */ const PureForm = ({ @@ -29,14 +28,9 @@ const PureForm = ({ onSubmit(values: FormSchema): void; onDelete?(): void; }) => { - const { - control, - getValues, - setValue, - watch, - handleSubmit, - formState: { errors }, - } = useForm({ + const { notifyError } = useDashboardNotification(); + + const { control, getValues, setValue, watch, handleSubmit, setError, clearErrors } = useForm({ defaultValues: defaultValues, resolver: zodResolver(ContentfulProviderConfig.Schema.Input.omit({ type: true })), }); @@ -45,6 +39,22 @@ const PureForm = ({ trpcClient.contentful.fetchContentTypesFromApi.useMutation({ onSuccess(data) { setValue("contentId", data.items[0].sys.id ?? null); + + clearErrors(["authToken", "spaceId"]); + }, + onError() { + setError("authToken", { + type: "custom", + message: "Invalid credentials", + }); + setError("spaceId", { + type: "custom", + message: "Invalid credentials", + }); + notifyError( + "Error", + "Could not fetch content types from Contentful. Please check your credentials." + ); }, }); @@ -52,6 +62,22 @@ const PureForm = ({ trpcClient.contentful.fetchEnvironmentsFromApi.useMutation({ onSuccess(data) { setValue("environment", data.items[0].sys.id); + + clearErrors(["authToken", "spaceId"]); + }, + onError() { + setError("authToken", { + type: "custom", + message: "Invalid credentials", + }); + setError("spaceId", { + type: "custom", + message: "Invalid credentials", + }); + notifyError( + "Error", + "Could not fetch environments from Contentful. Please check your credentials." + ); }, }); @@ -289,7 +315,7 @@ const AddVariant = () => { const { mutate } = trpcClient.providersConfigs.addOne.useMutation({ onSuccess() { - notifySuccess("Success", "Updated configuration"); + notifySuccess("Success", "Saved configuration"); push("/configuration"); }, }); diff --git a/apps/cms-v2/src/modules/providers/contentful/contentful-webhooks-processor.ts b/apps/cms-v2/src/modules/providers/contentful/contentful-webhooks-processor.ts index d95bc60..3e9b1f1 100644 --- a/apps/cms-v2/src/modules/providers/contentful/contentful-webhooks-processor.ts +++ b/apps/cms-v2/src/modules/providers/contentful/contentful-webhooks-processor.ts @@ -1,3 +1,4 @@ +import { createLogger } from "@saleor/apps-shared"; import { WebhookProductFragment, WebhookProductVariantFragment, @@ -17,6 +18,7 @@ export type ContentfulClientFactory = ( export class ContentfulWebhooksProcessor implements ProductWebhooksProcessor { private client: ContentfulClientStrip; + private logger = createLogger({ name: "ContentfulWebhooksProcessor" }); constructor( private providerConfig: ContentfulProviderConfig.FullShape, @@ -30,18 +32,24 @@ export class ContentfulWebhooksProcessor implements ProductWebhooksProcessor { } async onProductVariantUpdated(productVariant: WebhookProductVariantFragment): Promise { + this.logger.trace("onProductVariantUpdated called"); + await this.client.upsertProductVariant({ configuration: this.providerConfig, variant: productVariant, }); } async onProductVariantCreated(productVariant: WebhookProductVariantFragment): Promise { + this.logger.trace("onProductVariantCreated called"); + await this.client.upsertProductVariant({ configuration: this.providerConfig, variant: productVariant, }); } async onProductVariantDeleted(productVariant: WebhookProductVariantFragment): Promise { + this.logger.trace("onProductVariantDeleted called"); + await this.client.deleteProductVariant({ configuration: this.providerConfig, variant: productVariant, @@ -49,6 +57,8 @@ export class ContentfulWebhooksProcessor implements ProductWebhooksProcessor { } async onProductUpdated(product: WebhookProductFragment): Promise { + this.logger.trace("onProductUpdated called"); + await Promise.all( (product.variants ?? []).map((variant) => { return this.client.upsertProductVariant({ diff --git a/apps/cms-v2/src/modules/providers/contentful/contentful.router.ts b/apps/cms-v2/src/modules/providers/contentful/contentful.router.ts index 395638c..790c846 100644 --- a/apps/cms-v2/src/modules/providers/contentful/contentful.router.ts +++ b/apps/cms-v2/src/modules/providers/contentful/contentful.router.ts @@ -7,6 +7,8 @@ import { protectedClientProcedure } from "../../trpc/protected-client-procedure" import { router } from "../../trpc/trpc-server"; import { ContentfulClient } from "./contentful-client"; +import { createFlatProxy } from "@trpc/server/shared"; +import { createLogger } from "@saleor/apps-shared"; const procedure = protectedClientProcedure.use(({ ctx, next }) => { const settingsManager = createSettingsManager(ctx.apiClient, ctx.appId!); @@ -15,6 +17,7 @@ const procedure = protectedClientProcedure.use(({ ctx, next }) => { ctx: { settingsManager, appConfigService: new AppConfigMetadataManager(settingsManager), + logger: createLogger({ name: "contentfulRouter" }), }, }); }); @@ -38,7 +41,11 @@ export const contentfulRouter = router({ space: input.contentfulSpace, }); - return client.getEnvironments(); + return client.getEnvironments().catch((e) => { + ctx.logger.error("Failed to fetch environments"); + + throw new TRPCError({ code: "BAD_REQUEST" }); + }); }), fetchContentTypesFromApi: procedure .input( @@ -55,7 +62,7 @@ export const contentfulRouter = router({ }); return client.getContentTypes(input.contentfulEnv).catch((e) => { - console.error(e); + ctx.logger.error("Failed to fetch content types"); throw new TRPCError({ code: "BAD_REQUEST" }); }); diff --git a/apps/cms-v2/src/modules/providers/datocms/datocms-bulk-sync-processor.ts b/apps/cms-v2/src/modules/providers/datocms/datocms-bulk-sync-processor.ts index 1c6e957..05f05f5 100644 --- a/apps/cms-v2/src/modules/providers/datocms/datocms-bulk-sync-processor.ts +++ b/apps/cms-v2/src/modules/providers/datocms/datocms-bulk-sync-processor.ts @@ -3,6 +3,7 @@ import { BulkImportProductFragment } from "../../../../generated/graphql"; import { BulkSyncProcessor, BulkSyncProcessorHooks } from "../../bulk-sync/bulk-sync-processor"; import { DatoCMSClient } from "./datocms-client"; +import { createLogger } from "@saleor/apps-shared"; export class DatocmsBulkSyncProcessor implements BulkSyncProcessor { constructor(private config: DatocmsProviderConfig.FullShape) {} diff --git a/apps/cms-v2/src/modules/providers/datocms/datocms-client.ts b/apps/cms-v2/src/modules/providers/datocms/datocms-client.ts index 9b36cb9..02c3bb2 100644 --- a/apps/cms-v2/src/modules/providers/datocms/datocms-client.ts +++ b/apps/cms-v2/src/modules/providers/datocms/datocms-client.ts @@ -5,6 +5,7 @@ import { z } from "zod"; import * as Sentry from "@sentry/nextjs"; import { DatocmsProviderConfig } from "@/modules/configuration/schemas/datocms-provider.schema"; +import { FieldsMapper } from "../fields-mapper"; type Context = { configuration: DatocmsProviderConfig.FullShape; @@ -61,16 +62,21 @@ export class DatoCMSClient { configuration, variant, }: Context): SimpleSchemaTypes.ItemCreateSchema { - const fieldsMap = configuration.productVariantFieldsMapping; + const fields = FieldsMapper.mapProductVariantToConfigurationFields({ + variant, + configMapping: configuration.productVariantFieldsMapping, + }); + + /** + * Dato requires JSON to be stringified first so overwrite this single fields + */ + fields[configuration.productVariantFieldsMapping.channels] = JSON.stringify( + variant.channelListings + ); return { item_type: { type: "item_type", id: configuration.itemType }, - [fieldsMap.variantName]: variant.name, - [fieldsMap.productId]: variant.product.id, - [fieldsMap.productName]: variant.product.name, - [fieldsMap.productSlug]: variant.product.slug, - [fieldsMap.variantId]: variant.id, - [fieldsMap.channels]: JSON.stringify(variant.channelListings), + ...fields, }; } diff --git a/apps/cms-v2/src/modules/providers/datocms/datocms-config-form.tsx b/apps/cms-v2/src/modules/providers/datocms/datocms-config-form.tsx index 272e190..42dea79 100644 --- a/apps/cms-v2/src/modules/providers/datocms/datocms-config-form.tsx +++ b/apps/cms-v2/src/modules/providers/datocms/datocms-config-form.tsx @@ -19,12 +19,52 @@ type PureFormProps = { onDelete?(): void; }; -const useDatoCmsRemoteFields = () => { +/* + * todo react on token change, refresh mutation + */ +const PureForm = ({ defaultValues, onSubmit, onDelete }: PureFormProps) => { + const { notifyError } = useDashboardNotification(); + + const { control, getValues, setValue, watch, handleSubmit, clearErrors, setError } = useForm({ + defaultValues: defaultValues, + resolver: zodResolver(DatocmsProviderConfig.Schema.Input.omit({ type: true })), + }); + const { mutate: fetchContentTypes, data: contentTypesData } = - trpcClient.datocms.fetchContentTypes.useMutation(); + trpcClient.datocms.fetchContentTypes.useMutation({ + onSuccess(data) { + clearErrors("authToken"); + }, + onError() { + setError("authToken", { + type: "custom", + message: "Invalid credentials", + }); + + notifyError( + "Error", + "Could not fetch content types from DatoCMS. Please check your credentials." + ); + }, + }); const { mutate: fetchContentTypeFields, data: fieldsData } = - trpcClient.datocms.fetchContentTypeFields.useMutation(); + trpcClient.datocms.fetchContentTypeFields.useMutation({ + onSuccess(data) { + clearErrors("authToken"); + }, + onError() { + setError("authToken", { + type: "custom", + message: "Invalid credentials", + }); + + notifyError( + "Error", + "Could not fetch content types from DatoCMS. Please check your credentials." + ); + }, + }); const contentTypesSelectOptions = useMemo(() => { if (!contentTypesData) { @@ -37,40 +77,6 @@ const useDatoCmsRemoteFields = () => { })); }, [contentTypesData]); - return { - fetchContentTypes, - contentTypesData, - contentTypesSelectOptions, - fetchContentTypeFields, - fieldsData, - }; -}; - -/* - * todo react on token error - * todo react on token change, refresh mutation - */ -const PureForm = ({ defaultValues, onSubmit, onDelete }: PureFormProps) => { - const { - control, - getValues, - setValue, - watch, - handleSubmit, - formState: { errors }, - } = useForm({ - defaultValues: defaultValues, - resolver: zodResolver(DatocmsProviderConfig.Schema.Input.omit({ type: true })), - }); - - const { - contentTypesData, - fetchContentTypes, - contentTypesSelectOptions, - fetchContentTypeFields, - fieldsData, - } = useDatoCmsRemoteFields(); - const selectedContentType = watch("itemType"); useEffect(() => { @@ -225,7 +231,7 @@ const AddFormVariant = () => { const { notifySuccess } = useDashboardNotification(); const { mutate: addProvider } = trpcClient.providersConfigs.addOne.useMutation({ onSuccess() { - notifySuccess("Success", "Updated configuration"); + notifySuccess("Success", "Saved configuration"); push("/configuration"); }, }); diff --git a/apps/cms-v2/src/modules/providers/datocms/datocms-webhooks-processor.ts b/apps/cms-v2/src/modules/providers/datocms/datocms-webhooks-processor.ts index 8e8f3f1..a401d12 100644 --- a/apps/cms-v2/src/modules/providers/datocms/datocms-webhooks-processor.ts +++ b/apps/cms-v2/src/modules/providers/datocms/datocms-webhooks-processor.ts @@ -6,12 +6,14 @@ import { import { ProductWebhooksProcessor } from "../../webhooks-operations/product-webhooks-processor"; import { DatoCMSClient } from "./datocms-client"; +import { createLogger } from "@saleor/apps-shared"; /* * todo error handling */ export class DatocmsWebhooksProcessor implements ProductWebhooksProcessor { private client: DatoCMSClient; + private logger = createLogger({ name: "DatocmsWebhooksProcessor" }); constructor(private providerConfig: DatocmsProviderConfig.FullShape) { this.client = new DatoCMSClient({ @@ -20,18 +22,25 @@ export class DatocmsWebhooksProcessor implements ProductWebhooksProcessor { } async onProductVariantUpdated(productVariant: WebhookProductVariantFragment): Promise { + this.logger.trace("onProductVariantUpdated called"); + await this.client.updateProductVariant({ configuration: this.providerConfig, variant: productVariant, }); } + async onProductVariantCreated(productVariant: WebhookProductVariantFragment): Promise { + this.logger.trace("onProductVariantCreated called"); + await this.client.uploadProductVariant({ configuration: this.providerConfig, variant: productVariant, }); } async onProductVariantDeleted(productVariant: WebhookProductVariantFragment): Promise { + this.logger.trace("onProductVariantDeleted called"); + await this.client.deleteProductVariant({ configuration: this.providerConfig, variant: productVariant, @@ -39,6 +48,8 @@ export class DatocmsWebhooksProcessor implements ProductWebhooksProcessor { } async onProductUpdated(product: WebhookProductFragment): Promise { + this.logger.trace("onProductUpdated called"); + await Promise.all( (product.variants ?? []).map((variant) => { return this.client.upsertProduct({ diff --git a/apps/cms-v2/src/modules/providers/datocms/datocms.router.ts b/apps/cms-v2/src/modules/providers/datocms/datocms.router.ts index f4f6c39..7565f1f 100644 --- a/apps/cms-v2/src/modules/providers/datocms/datocms.router.ts +++ b/apps/cms-v2/src/modules/providers/datocms/datocms.router.ts @@ -4,6 +4,7 @@ import { protectedClientProcedure } from "../../trpc/protected-client-procedure" import { router } from "../../trpc/trpc-server"; import { DatoCMSClient } from "./datocms-client"; +import { TRPCError } from "@trpc/server"; /** * Operations specific for Datocms service. @@ -22,7 +23,11 @@ export const datocmsRouter = router({ apiToken: input.apiToken, }); - return client.getContentTypes(); + return client.getContentTypes().catch((err) => { + throw new TRPCError({ + code: "BAD_REQUEST", + }); + }); }), fetchContentTypeFields: protectedClientProcedure @@ -37,8 +42,14 @@ export const datocmsRouter = router({ apiToken: input.apiToken, }); - return client.getFieldsForContentType({ - itemTypeID: input.contentTypeID, - }); + return client + .getFieldsForContentType({ + itemTypeID: input.contentTypeID, + }) + .catch((err) => { + throw new TRPCError({ + code: "BAD_REQUEST", + }); + }); }), }); diff --git a/apps/cms-v2/src/modules/providers/fields-mapper.test.ts b/apps/cms-v2/src/modules/providers/fields-mapper.test.ts new file mode 100644 index 0000000..72a8933 --- /dev/null +++ b/apps/cms-v2/src/modules/providers/fields-mapper.test.ts @@ -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", + }, + }, + ], + }); + }); +}); diff --git a/apps/cms-v2/src/modules/providers/fields-mapper.ts b/apps/cms-v2/src/modules/providers/fields-mapper.ts new file mode 100644 index 0000000..1cdc701 --- /dev/null +++ b/apps/cms-v2/src/modules/providers/fields-mapper.ts @@ -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, + }; + } +} diff --git a/apps/cms-v2/src/modules/providers/strapi/strapi-client.ts b/apps/cms-v2/src/modules/providers/strapi/strapi-client.ts index 31283c5..47a602b 100644 --- a/apps/cms-v2/src/modules/providers/strapi/strapi-client.ts +++ b/apps/cms-v2/src/modules/providers/strapi/strapi-client.ts @@ -3,6 +3,7 @@ import { StrapiProviderConfig } from "@/modules/configuration"; import { WebhookProductVariantFragment } from "../../../../generated/graphql"; import { z } from "zod"; import { createLogger } from "@saleor/apps-shared"; +import { FieldsMapper } from "../fields-mapper"; // partial response const strapiFindOperationResult = z.object({ @@ -80,21 +81,12 @@ export class StrapiClient { }) { this.logger.trace({ variantId: variant.id }, "Will upload product variant"); - try { - const result = await this.client.create(configuration.itemType, { - // todo extract to common mapping function - [configuration.productVariantFieldsMapping.variantName]: variant.name, - [configuration.productVariantFieldsMapping.variantId]: variant.id, - [configuration.productVariantFieldsMapping.productName]: variant.product.name, - [configuration.productVariantFieldsMapping.productId]: variant.product.id, - [configuration.productVariantFieldsMapping.channels]: variant.channelListings, - [configuration.productVariantFieldsMapping.productSlug]: variant.product.slug, - }); + const mappedFields = FieldsMapper.mapProductVariantToConfigurationFields({ + variant, + configMapping: configuration.productVariantFieldsMapping, + }); - return result; - } catch (e) { - console.error(e); - } + return this.client.create(configuration.itemType, mappedFields); } async updateProduct({ @@ -120,22 +112,16 @@ export class StrapiClient { this.logger.trace({ strapiProductIdsToUpdate }, "Will try to update strapi products"); - try { - return Promise.all( - strapiProductIdsToUpdate.map((strapiProductId) => { - return this.client.update(configuration.itemType, strapiProductId, { - [configuration.productVariantFieldsMapping.variantName]: variant.name, - [configuration.productVariantFieldsMapping.variantId]: variant.id, - [configuration.productVariantFieldsMapping.productName]: variant.product.name, - [configuration.productVariantFieldsMapping.productId]: variant.product.id, - [configuration.productVariantFieldsMapping.channels]: variant.channelListings, - [configuration.productVariantFieldsMapping.productSlug]: variant.product.slug, - }); - }) - ); - } catch (e) { - console.error(e); - } + const mappedFields = FieldsMapper.mapProductVariantToConfigurationFields({ + variant, + configMapping: configuration.productVariantFieldsMapping, + }); + + return Promise.all( + strapiProductIdsToUpdate.map((strapiProductId) => { + return this.client.update(configuration.itemType, strapiProductId, mappedFields); + }) + ); } async upsertProduct({ diff --git a/apps/cms-v2/src/modules/providers/strapi/strapi-config-form.tsx b/apps/cms-v2/src/modules/providers/strapi/strapi-config-form.tsx index 20d64d7..e444879 100644 --- a/apps/cms-v2/src/modules/providers/strapi/strapi-config-form.tsx +++ b/apps/cms-v2/src/modules/providers/strapi/strapi-config-form.tsx @@ -8,6 +8,7 @@ import { SaleorProviderFieldsMappingKeys, StrapiProviderConfig } from "../../con import { printSaleorProductFields } from "../../configuration/print-saleor-product-fields"; import { trpcClient } from "../../trpc/trpc-client"; import { ButtonsBox } from "../../ui/buttons-box"; +import { Skeleton } from "@/modules/ui/skeleton"; type FormShape = Omit; @@ -18,14 +19,7 @@ type PureFormProps = { }; const PureForm = ({ defaultValues, onSubmit, onDelete }: PureFormProps) => { - const { - control, - getValues, - setValue, - watch, - handleSubmit, - formState: { errors }, - } = useForm({ + const { control, handleSubmit } = useForm({ defaultValues: defaultValues, resolver: zodResolver(StrapiProviderConfig.Schema.Input.omit({ type: true })), }); @@ -137,7 +131,7 @@ const AddFormVariant = () => { const { notifySuccess } = useDashboardNotification(); const { mutate: addProvider } = trpcClient.providersConfigs.addOne.useMutation({ onSuccess() { - notifySuccess("Success", "Updated configuration"); + notifySuccess("Success", "Saved configuration"); push("/configuration"); }, }); @@ -195,7 +189,7 @@ const EditFormVariant = (props: { configId: string }) => { }); if (!data) { - return null; + return ; } if (data.type !== "strapi") { diff --git a/apps/cms-v2/src/modules/providers/strapi/strapi-webhooks-processor.ts b/apps/cms-v2/src/modules/providers/strapi/strapi-webhooks-processor.ts index df9f4ff..fd94a9b 100644 --- a/apps/cms-v2/src/modules/providers/strapi/strapi-webhooks-processor.ts +++ b/apps/cms-v2/src/modules/providers/strapi/strapi-webhooks-processor.ts @@ -1,3 +1,4 @@ +import { createLogger } from "@saleor/apps-shared"; import { WebhookProductFragment, WebhookProductVariantFragment, @@ -8,22 +9,31 @@ import { StrapiClient } from "./strapi-client"; export class StrapiWebhooksProcessor implements ProductWebhooksProcessor { private client: StrapiClient; + private logger = createLogger({ name: "StrapiWebhooksProcessor" }); constructor(private config: StrapiProviderConfig.FullShape) { this.client = new StrapiClient({ url: config.url, token: config.authToken }); } async onProductVariantUpdated(productVariant: WebhookProductVariantFragment): Promise { + this.logger.trace("onProductVariantUpdated called"); + this.client.updateProduct({ configuration: this.config, variant: productVariant }); } async onProductVariantCreated(productVariant: WebhookProductVariantFragment): Promise { + this.logger.trace("onProductVariantCreated called"); + this.client.uploadProduct({ configuration: this.config, variant: productVariant }); } async onProductVariantDeleted(productVariant: WebhookProductVariantFragment): Promise { + this.logger.trace("onProductVariantDeleted called"); + this.client.deleteProduct({ configuration: this.config, variant: productVariant }); } async onProductUpdated(product: WebhookProductFragment): Promise { + this.logger.trace("onProductUpdated called"); + await Promise.all( (product.variants ?? []).map((variant) => { return this.client.upsertProduct({ diff --git a/apps/cms-v2/src/modules/ui/skeleton.tsx b/apps/cms-v2/src/modules/ui/skeleton.tsx new file mode 100644 index 0000000..0c061e8 --- /dev/null +++ b/apps/cms-v2/src/modules/ui/skeleton.tsx @@ -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 ( + + + + + + ); +}; + +export const Skeleton = { Section }; diff --git a/apps/cms-v2/src/modules/webhooks-operations/webhooks-processors-delegator.ts b/apps/cms-v2/src/modules/webhooks-operations/webhooks-processors-delegator.ts index d5bc3f8..8e8e808 100644 --- a/apps/cms-v2/src/modules/webhooks-operations/webhooks-processors-delegator.ts +++ b/apps/cms-v2/src/modules/webhooks-operations/webhooks-processors-delegator.ts @@ -80,6 +80,8 @@ export class WebhooksProcessorsDelegator { } async delegateVariantUpdatedOperations(productVariant: WebhookProductVariantFragment) { + this.logger.trace("delegateVariantUpdatedOperations called"); + const { connections } = this.opts.context; const relatedVariantChannels = this.extractChannelSlugsFromProductVariant(productVariant); @@ -91,6 +93,11 @@ export class WebhooksProcessorsDelegator { relatedVariantChannels.includes(conn.channelSlug) ); + this.logger.trace( + { connections: connectionsToInclude.length }, + "Resolved a number of connections to include" + ); + const processors = this.mapConnectionsToProcessors(connectionsToInclude); return Promise.all( @@ -101,8 +108,15 @@ export class WebhooksProcessorsDelegator { } async delegateVariantDeletedOperations(productVariant: WebhookProductVariantFragment) { + this.logger.trace("delegateVariantDeletedOperations called"); + const { connections } = this.opts.context; + this.logger.trace( + { connections: connections.length }, + "Resolved a number of connections to include" + ); + const processors = this.mapConnectionsToProcessors(connections); return Promise.all( @@ -113,8 +127,15 @@ export class WebhooksProcessorsDelegator { } async delegateProductUpdatedOperations(product: WebhookProductFragment) { + this.logger.trace("delegateProductUpdatedOperations called"); + const { connections } = this.opts.context; + this.logger.trace( + { connections: connections.length }, + "Resolved a number of connections to include" + ); + const processors = this.mapConnectionsToProcessors(connections); return Promise.all( diff --git a/apps/cms-v2/src/pages/bulk-sync/[connection-id].tsx b/apps/cms-v2/src/pages/bulk-sync/[connection-id].tsx index a7cc834..1bec1de 100644 --- a/apps/cms-v2/src/pages/bulk-sync/[connection-id].tsx +++ b/apps/cms-v2/src/pages/bulk-sync/[connection-id].tsx @@ -4,6 +4,7 @@ import { NextPage } from "next"; import { useRouter } from "next/router"; import { z } from "zod"; import { Text } from "@saleor/macaw-ui/next"; +import { Skeleton } from "@/modules/ui/skeleton"; const BulkSyncPage: NextPage = () => { const { query } = useRouter(); @@ -44,7 +45,7 @@ const BulkSyncPage: NextPage = () => { } if (connectionLoading || providerLoading) { - return Loading...; + return ; } if (!(provider && connection)) { diff --git a/apps/cms-v2/src/pages/configuration.tsx b/apps/cms-v2/src/pages/configuration.tsx index 8055857..bd9e7b7 100644 --- a/apps/cms-v2/src/pages/configuration.tsx +++ b/apps/cms-v2/src/pages/configuration.tsx @@ -37,15 +37,11 @@ const ConfigurationPage: NextPage = () => { heading="Initial sync" sideContent={ - + Perform an initial scan of product variants to push existing database to selected provider. Its recommended to run this flow initially, once app is configured. - - Warning: removing products in Saleor will not remove them in CMS. To create a fully - updated. - } mainContent={} diff --git a/apps/cms-v2/src/pages/edit-provider/[configId].tsx b/apps/cms-v2/src/pages/edit-provider/[configId].tsx index 1eca966..9562223 100644 --- a/apps/cms-v2/src/pages/edit-provider/[configId].tsx +++ b/apps/cms-v2/src/pages/edit-provider/[configId].tsx @@ -3,6 +3,7 @@ import { ProvidersResolver } from "@/modules/providers/providers-resolver"; import { trpcClient } from "@/modules/trpc/trpc-client"; import { AppHeader } from "@/modules/ui/app-header"; import { AppSection } from "@/modules/ui/app-section"; +import { Skeleton } from "@/modules/ui/skeleton"; import { Breadcrumbs } from "@saleor/apps-ui"; import { Box, Text } from "@saleor/macaw-ui/next"; import { NextPage } from "next"; @@ -27,7 +28,7 @@ const EditProviderPage: NextPage = () => { }, [data]); if (isLoading) { - return Loading...; + return ; } if (isFetched && !data) { @@ -37,7 +38,7 @@ const EditProviderPage: NextPage = () => { } if (!provider) { - return null; + return ; } const EditForm = ProvidersResolver.getEditProviderFormComponent(provider.type);