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:
parent
44333a6784
commit
5d41af93e1
38 changed files with 541 additions and 240 deletions
5
.changeset/chilly-donuts-allow.md
Normal file
5
.changeset/chilly-donuts-allow.md
Normal 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.
|
10
.changeset/few-hornets-argue.md
Normal file
10
.changeset/few-hornets-argue.md
Normal 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
|
5
.changeset/three-yaks-attack.md
Normal file
5
.changeset/three-yaks-attack.md
Normal 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
|
|
@ -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) {
|
||||||
|
|
|
@ -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");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { ArrowRightIcon, Box } from "@saleor/macaw-ui/next";
|
import { ArrowRightIcon, Box, Text } from "@saleor/macaw-ui/next";
|
||||||
import { Select } from "@saleor/react-hook-form-macaw";
|
import { Select } from "@saleor/react-hook-form-macaw";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { ChannelProviderConnectionConfig } from "../configuration";
|
import { ChannelProviderConnectionConfig } from "../configuration";
|
||||||
import { trpcClient } from "../trpc/trpc-client";
|
import { trpcClient } from "../trpc/trpc-client";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
export type AddConnectionFormSchema = Omit<
|
export type AddConnectionFormSchema = Omit<
|
||||||
ChannelProviderConnectionConfig.InputShape,
|
ChannelProviderConnectionConfig.InputShape,
|
||||||
|
@ -14,51 +15,71 @@ export const AddConnectionFormID = "new-connection-form";
|
||||||
|
|
||||||
export const AddConnectionForm = (props: {
|
export const AddConnectionForm = (props: {
|
||||||
defaultValues: AddConnectionFormSchema;
|
defaultValues: AddConnectionFormSchema;
|
||||||
onSubmit(values: AddConnectionFormSchema): void;
|
onSubmit(values: AddConnectionFormSchema): Promise<void>;
|
||||||
}) => {
|
}) => {
|
||||||
const { data: channels } = trpcClient.channelsProvidersConnection.fetchAllChannels.useQuery();
|
const { data: channels } = trpcClient.channelsProvidersConnection.fetchAllChannels.useQuery();
|
||||||
const { data: providers } = trpcClient.providersConfigs.getAll.useQuery();
|
const { data: providers } = trpcClient.providersConfigs.getAll.useQuery();
|
||||||
|
|
||||||
const { handleSubmit, control } = useForm<AddConnectionFormSchema>({
|
const {
|
||||||
|
handleSubmit,
|
||||||
|
control,
|
||||||
|
setError,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm({
|
||||||
defaultValues: props.defaultValues,
|
defaultValues: props.defaultValues,
|
||||||
resolver: zodResolver(
|
resolver: zodResolver(
|
||||||
ChannelProviderConnectionConfig.Schema.Input.omit({ providerType: true })
|
ChannelProviderConnectionConfig.Schema.Input.omit({ providerType: true })
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const onSubmit = (values: AddConnectionFormSchema) => {
|
||||||
|
props.onSubmit(values).catch((err: TRPCError) => {
|
||||||
|
setError("channelSlug", {
|
||||||
|
type: "manual",
|
||||||
|
});
|
||||||
|
setError("providerId", {
|
||||||
|
type: "manual",
|
||||||
|
});
|
||||||
|
|
||||||
|
setError("root.serverError", {
|
||||||
|
type: "manual",
|
||||||
|
message: err.message,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box onSubmit={handleSubmit(onSubmit)} as="form" id={AddConnectionFormID}>
|
||||||
onSubmit={handleSubmit(props.onSubmit)}
|
<Box display="grid" __gridTemplateColumns="1fr auto 1fr" gap={4} alignItems="center">
|
||||||
as="form"
|
<Select
|
||||||
id={AddConnectionFormID}
|
required
|
||||||
display="grid"
|
size="small"
|
||||||
__gridTemplateColumns="1fr auto 1fr"
|
control={control}
|
||||||
gap={4}
|
name="channelSlug"
|
||||||
alignItems="center"
|
label="Channel"
|
||||||
>
|
options={channels?.map((c) => ({
|
||||||
<Select
|
value: c.slug,
|
||||||
required
|
label: c.name,
|
||||||
size="small"
|
}))}
|
||||||
control={control}
|
/>
|
||||||
name="channelSlug"
|
<ArrowRightIcon />
|
||||||
label="Channel"
|
<Select
|
||||||
options={channels?.map((c) => ({
|
required
|
||||||
value: c.slug,
|
size="small"
|
||||||
label: c.name,
|
control={control}
|
||||||
}))}
|
name="providerId"
|
||||||
/>
|
label="Provider"
|
||||||
<ArrowRightIcon />
|
options={providers?.map((p) => ({
|
||||||
<Select
|
value: p.id,
|
||||||
required
|
label: p.configName,
|
||||||
size="small"
|
}))}
|
||||||
control={control}
|
/>
|
||||||
name="providerId"
|
</Box>
|
||||||
label="Provider"
|
{errors.root?.serverError && (
|
||||||
options={providers?.map((p) => ({
|
<Text as="p" marginTop={2} color="textCriticalSubdued">
|
||||||
value: p.id,
|
{errors.root?.serverError.message}
|
||||||
label: p.configName,
|
</Text>
|
||||||
}))}
|
)}
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -6,34 +6,62 @@ import {
|
||||||
AddConnectionFormID,
|
AddConnectionFormID,
|
||||||
AddConnectionFormSchema,
|
AddConnectionFormSchema,
|
||||||
} from "./add-connection-form";
|
} from "./add-connection-form";
|
||||||
|
import { trpcClient } from "../trpc/trpc-client";
|
||||||
|
import { Skeleton } from "../ui/skeleton";
|
||||||
|
|
||||||
const defaultValues: AddConnectionFormSchema = { channelSlug: "", providerId: "" };
|
const defaultValues: AddConnectionFormSchema = { channelSlug: "", providerId: "" };
|
||||||
|
|
||||||
export const AddConnectionModal = (props: {
|
export const AddConnectionModal = (props: { onSuccess(): void; onClose(): void }) => {
|
||||||
onSubmit(values: AddConnectionFormSchema): void;
|
const { data: providers } = trpcClient.providersConfigs.getAll.useQuery();
|
||||||
onClose(): void;
|
|
||||||
}) => (
|
if (!providers) {
|
||||||
<Modal onClose={props.onClose}>
|
return <Skeleton.Section />;
|
||||||
<Text as="h2" variant="heading">
|
}
|
||||||
Connect channel with Provider
|
|
||||||
</Text>
|
const { mutateAsync: addProviderMutate, isLoading } =
|
||||||
<Text as="p" marginBottom={6}>
|
trpcClient.channelsProvidersConnection.addConnection.useMutation({
|
||||||
Once connected, operations on product variants on this channel will be sent to selected CMS
|
onSuccess() {
|
||||||
platform.
|
props.onSuccess();
|
||||||
</Text>
|
},
|
||||||
<AddConnectionForm onSubmit={props.onSubmit} defaultValues={defaultValues} />
|
});
|
||||||
<ButtonsBox marginTop={8}>
|
|
||||||
<Button
|
const handleFormSubmit = async (values: AddConnectionFormSchema) => {
|
||||||
variant="tertiary"
|
const providerType = providers.find((p) => p.id === values.providerId)?.type;
|
||||||
onClick={() => {
|
|
||||||
props.onClose();
|
if (!providerType) {
|
||||||
}}
|
throw new Error("Provider not found");
|
||||||
>
|
}
|
||||||
Close
|
|
||||||
</Button>
|
return addProviderMutate({
|
||||||
<Button variant="primary" type="submit" form={AddConnectionFormID}>
|
...values,
|
||||||
Add connection
|
providerType,
|
||||||
</Button>
|
});
|
||||||
</ButtonsBox>
|
};
|
||||||
</Modal>
|
|
||||||
);
|
return (
|
||||||
|
<Modal onClose={props.onClose}>
|
||||||
|
<Text as="h2" variant="heading">
|
||||||
|
Connect channel with Provider
|
||||||
|
</Text>
|
||||||
|
<Text as="p" marginBottom={6}>
|
||||||
|
Once connected, operations on product variants on this channel will be sent to selected CMS
|
||||||
|
platform.
|
||||||
|
</Text>
|
||||||
|
<AddConnectionForm onSubmit={handleFormSubmit} defaultValues={defaultValues} />
|
||||||
|
<ButtonsBox marginTop={8}>
|
||||||
|
<Button
|
||||||
|
disabled={isLoading}
|
||||||
|
variant="tertiary"
|
||||||
|
onClick={() => {
|
||||||
|
props.onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" type="submit" form={AddConnectionFormID}>
|
||||||
|
Add connection
|
||||||
|
</Button>
|
||||||
|
</ButtonsBox>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { protectedClientProcedure } from "@/modules/trpc/protected-client-proced
|
||||||
import { router } from "@/modules/trpc/trpc-server";
|
import { router } from "@/modules/trpc/trpc-server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { FetchChannelsDocument } from "../../../generated/graphql";
|
import { FetchChannelsDocument } from "../../../generated/graphql";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
const procedure = protectedClientProcedure.use(({ ctx, next }) => {
|
const procedure = protectedClientProcedure.use(({ ctx, next }) => {
|
||||||
const settingsManager = createSettingsManager(ctx.apiClient, ctx.appId!);
|
const settingsManager = createSettingsManager(ctx.apiClient, ctx.appId!);
|
||||||
|
@ -33,7 +34,24 @@ export const channelProviderConnectionRouter = router({
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const config = await ctx.appConfigService.get();
|
const config = await ctx.appConfigService.get();
|
||||||
|
|
||||||
config.connections.addConnection(input);
|
try {
|
||||||
|
config.connections.addConnection(input);
|
||||||
|
} catch (e) {
|
||||||
|
switch ((e as { cause: string }).cause) {
|
||||||
|
case "PROVIDER_DOESNT_EXIST":
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
cause: "PROVIDER_DOESNT_EXIST",
|
||||||
|
message: "Provider doesnt exist",
|
||||||
|
});
|
||||||
|
case "CONNECTION_ALREADY_EXISTS":
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "CONFLICT",
|
||||||
|
cause: "CONNECTION_EXISTS",
|
||||||
|
message: "Connection already exists",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ctx.appConfigService.set(config);
|
ctx.appConfigService.set(config);
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -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 && (
|
||||||
|
|
|
@ -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 (
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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([
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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" });
|
||||||
});
|
});
|
||||||
|
|
|
@ -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) {}
|
||||||
|
|
|
@ -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),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { protectedClientProcedure } from "../../trpc/protected-client-procedure"
|
||||||
import { router } from "../../trpc/trpc-server";
|
import { router } from "../../trpc/trpc-server";
|
||||||
|
|
||||||
import { DatoCMSClient } from "./datocms-client";
|
import { DatoCMSClient } from "./datocms-client";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Operations specific for Datocms service.
|
* Operations specific for Datocms service.
|
||||||
|
@ -22,7 +23,11 @@ export const datocmsRouter = router({
|
||||||
apiToken: input.apiToken,
|
apiToken: input.apiToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
return client.getContentTypes();
|
return client.getContentTypes().catch((err) => {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
});
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
fetchContentTypeFields: protectedClientProcedure
|
fetchContentTypeFields: protectedClientProcedure
|
||||||
|
@ -37,8 +42,14 @@ export const datocmsRouter = router({
|
||||||
apiToken: input.apiToken,
|
apiToken: input.apiToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
return client.getFieldsForContentType({
|
return client
|
||||||
itemTypeID: input.contentTypeID,
|
.getFieldsForContentType({
|
||||||
});
|
itemTypeID: input.contentTypeID,
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
});
|
||||||
|
});
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
50
apps/cms-v2/src/modules/providers/fields-mapper.test.ts
Normal file
50
apps/cms-v2/src/modules/providers/fields-mapper.test.ts
Normal 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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
23
apps/cms-v2/src/modules/providers/fields-mapper.ts
Normal file
23
apps/cms-v2/src/modules/providers/fields-mapper.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,6 +3,7 @@ import { StrapiProviderConfig } from "@/modules/configuration";
|
||||||
import { WebhookProductVariantFragment } from "../../../../generated/graphql";
|
import { WebhookProductVariantFragment } from "../../../../generated/graphql";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createLogger } from "@saleor/apps-shared";
|
import { createLogger } from "@saleor/apps-shared";
|
||||||
|
import { FieldsMapper } from "../fields-mapper";
|
||||||
|
|
||||||
// partial response
|
// partial response
|
||||||
const strapiFindOperationResult = z.object({
|
const strapiFindOperationResult = z.object({
|
||||||
|
@ -80,21 +81,12 @@ export class StrapiClient {
|
||||||
}) {
|
}) {
|
||||||
this.logger.trace({ variantId: variant.id }, "Will upload product variant");
|
this.logger.trace({ variantId: variant.id }, "Will upload product variant");
|
||||||
|
|
||||||
try {
|
const mappedFields = FieldsMapper.mapProductVariantToConfigurationFields({
|
||||||
const result = await this.client.create(configuration.itemType, {
|
variant,
|
||||||
// todo extract to common mapping function
|
configMapping: configuration.productVariantFieldsMapping,
|
||||||
[configuration.productVariantFieldsMapping.variantName]: variant.name,
|
});
|
||||||
[configuration.productVariantFieldsMapping.variantId]: variant.id,
|
|
||||||
[configuration.productVariantFieldsMapping.productName]: variant.product.name,
|
|
||||||
[configuration.productVariantFieldsMapping.productId]: variant.product.id,
|
|
||||||
[configuration.productVariantFieldsMapping.channels]: variant.channelListings,
|
|
||||||
[configuration.productVariantFieldsMapping.productSlug]: variant.product.slug,
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
return this.client.create(configuration.itemType, mappedFields);
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateProduct({
|
async updateProduct({
|
||||||
|
@ -120,22 +112,16 @@ export class StrapiClient {
|
||||||
|
|
||||||
this.logger.trace({ strapiProductIdsToUpdate }, "Will try to update strapi products");
|
this.logger.trace({ strapiProductIdsToUpdate }, "Will try to update strapi products");
|
||||||
|
|
||||||
try {
|
const mappedFields = FieldsMapper.mapProductVariantToConfigurationFields({
|
||||||
return Promise.all(
|
variant,
|
||||||
strapiProductIdsToUpdate.map((strapiProductId) => {
|
configMapping: configuration.productVariantFieldsMapping,
|
||||||
return this.client.update(configuration.itemType, strapiProductId, {
|
});
|
||||||
[configuration.productVariantFieldsMapping.variantName]: variant.name,
|
|
||||||
[configuration.productVariantFieldsMapping.variantId]: variant.id,
|
return Promise.all(
|
||||||
[configuration.productVariantFieldsMapping.productName]: variant.product.name,
|
strapiProductIdsToUpdate.map((strapiProductId) => {
|
||||||
[configuration.productVariantFieldsMapping.productId]: variant.product.id,
|
return this.client.update(configuration.itemType, strapiProductId, mappedFields);
|
||||||
[configuration.productVariantFieldsMapping.channels]: variant.channelListings,
|
})
|
||||||
[configuration.productVariantFieldsMapping.productSlug]: variant.product.slug,
|
);
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async upsertProduct({
|
async upsertProduct({
|
||||||
|
|
|
@ -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") {
|
||||||
|
|
|
@ -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({
|
||||||
|
|
29
apps/cms-v2/src/modules/ui/skeleton.tsx
Normal file
29
apps/cms-v2/src/modules/ui/skeleton.tsx
Normal 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 };
|
|
@ -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(
|
||||||
|
|
|
@ -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)) {
|
||||||
|
|
|
@ -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 />}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
Loading…
Reference in a new issue