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 { ButtonsBox } from "../ui/buttons-box";
|
||||
import { ProvidersResolver } from "../providers/providers-resolver";
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
|
||||
const FormSchema = z.object({
|
||||
connectionId: z.string().min(7),
|
||||
|
@ -33,7 +34,7 @@ export const BulkSyncSection = () => {
|
|||
const { data: connections } = trpcClient.channelsProvidersConnection.fetchConnections.useQuery();
|
||||
const { data: providers } = trpcClient.providersConfigs.getAll.useQuery();
|
||||
|
||||
const { control, handleSubmit } = useForm<z.infer<typeof FormSchema>>({
|
||||
const { control, handleSubmit } = useForm({
|
||||
defaultValues: {
|
||||
connectionId: "",
|
||||
},
|
||||
|
@ -41,7 +42,7 @@ export const BulkSyncSection = () => {
|
|||
});
|
||||
|
||||
if (!connections || !providers) {
|
||||
return <Text>Loading...</Text>;
|
||||
return <Skeleton.Section />;
|
||||
}
|
||||
|
||||
if (connections.length === 0) {
|
||||
|
|
|
@ -10,6 +10,7 @@ import { useBulkSyncProductsState } from "./use-bulk-sync-products-state";
|
|||
import { useFetchAllProducts } from "./use-fetch-all-products";
|
||||
import { VariantsSyncStatusList } from "./variants-sync-status-list";
|
||||
import { ProvidersResolver } from "../providers/providers-resolver";
|
||||
import { useDashboardNotification } from "@saleor/apps-shared";
|
||||
|
||||
const FetchProductsStep = (props: { onButtonClick(): void }) => {
|
||||
return (
|
||||
|
@ -61,13 +62,21 @@ export const BulkSyncView = ({
|
|||
}) => {
|
||||
const processor = useRef(ProvidersResolver.createBulkSyncProcessor(configuration));
|
||||
const [state, setState] = useState<Status>("initial");
|
||||
const { notifySuccess } = useDashboardNotification();
|
||||
|
||||
const { products, finished: saleorProductsFetchFinished } = useFetchAllProducts(
|
||||
state === "fetching",
|
||||
connection.channelSlug
|
||||
);
|
||||
|
||||
const { productsStatusList, setInitialProducts, setItemStatus } = useBulkSyncProductsState();
|
||||
const { productsStatusList, setInitialProducts, setItemStatus, finished } =
|
||||
useBulkSyncProductsState();
|
||||
|
||||
useEffect(() => {
|
||||
if (finished) {
|
||||
notifySuccess("Bulk sync ended", "All products have been synced, please verify results");
|
||||
}
|
||||
}, [finished, notifySuccess]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!saleorProductsFetchFinished) {
|
||||
|
@ -91,7 +100,7 @@ export const BulkSyncView = ({
|
|||
setItemStatus(variantId, "success");
|
||||
},
|
||||
onUploadError({ error, variantId }) {
|
||||
// todo handle error
|
||||
// User will be notified about the error in the UI
|
||||
setItemStatus(variantId, "error");
|
||||
},
|
||||
});
|
||||
|
|
|
@ -1,13 +1,21 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { BulkImportProductFragment } from "../../../generated/graphql";
|
||||
import { VariantsSyncStatusListItem } from "./variants-sync-status-list";
|
||||
|
||||
export const useBulkSyncProductsState = () => {
|
||||
const [finished, setFinished] = useState(false);
|
||||
const [productsStatusList, setProductsStatusList] = useState<VariantsSyncStatusListItem[] | null>(
|
||||
null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (productsStatusList?.every((item) => item.status === "success" || item.status === "error")) {
|
||||
setFinished(true);
|
||||
}
|
||||
}, [productsStatusList]);
|
||||
|
||||
return {
|
||||
finished,
|
||||
productsStatusList,
|
||||
setInitialProducts: useCallback((products: BulkImportProductFragment[]) => {
|
||||
setProductsStatusList(
|
||||
|
|
|
@ -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<void>;
|
||||
}) => {
|
||||
const { data: channels } = trpcClient.channelsProvidersConnection.fetchAllChannels.useQuery();
|
||||
const { data: providers } = trpcClient.providersConfigs.getAll.useQuery();
|
||||
|
||||
const { handleSubmit, control } = useForm<AddConnectionFormSchema>({
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
setError,
|
||||
formState: { errors },
|
||||
} = useForm({
|
||||
defaultValues: props.defaultValues,
|
||||
resolver: zodResolver(
|
||||
ChannelProviderConnectionConfig.Schema.Input.omit({ providerType: true })
|
||||
),
|
||||
});
|
||||
|
||||
const onSubmit = (values: AddConnectionFormSchema) => {
|
||||
props.onSubmit(values).catch((err: TRPCError) => {
|
||||
setError("channelSlug", {
|
||||
type: "manual",
|
||||
});
|
||||
setError("providerId", {
|
||||
type: "manual",
|
||||
});
|
||||
|
||||
setError("root.serverError", {
|
||||
type: "manual",
|
||||
message: err.message,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
onSubmit={handleSubmit(props.onSubmit)}
|
||||
as="form"
|
||||
id={AddConnectionFormID}
|
||||
display="grid"
|
||||
__gridTemplateColumns="1fr auto 1fr"
|
||||
gap={4}
|
||||
alignItems="center"
|
||||
>
|
||||
<Select
|
||||
required
|
||||
size="small"
|
||||
control={control}
|
||||
name="channelSlug"
|
||||
label="Channel"
|
||||
options={channels?.map((c) => ({
|
||||
value: c.slug,
|
||||
label: c.name,
|
||||
}))}
|
||||
/>
|
||||
<ArrowRightIcon />
|
||||
<Select
|
||||
required
|
||||
size="small"
|
||||
control={control}
|
||||
name="providerId"
|
||||
label="Provider"
|
||||
options={providers?.map((p) => ({
|
||||
value: p.id,
|
||||
label: p.configName,
|
||||
}))}
|
||||
/>
|
||||
<Box onSubmit={handleSubmit(onSubmit)} as="form" id={AddConnectionFormID}>
|
||||
<Box display="grid" __gridTemplateColumns="1fr auto 1fr" gap={4} alignItems="center">
|
||||
<Select
|
||||
required
|
||||
size="small"
|
||||
control={control}
|
||||
name="channelSlug"
|
||||
label="Channel"
|
||||
options={channels?.map((c) => ({
|
||||
value: c.slug,
|
||||
label: c.name,
|
||||
}))}
|
||||
/>
|
||||
<ArrowRightIcon />
|
||||
<Select
|
||||
required
|
||||
size="small"
|
||||
control={control}
|
||||
name="providerId"
|
||||
label="Provider"
|
||||
options={providers?.map((p) => ({
|
||||
value: p.id,
|
||||
label: p.configName,
|
||||
}))}
|
||||
/>
|
||||
</Box>
|
||||
{errors.root?.serverError && (
|
||||
<Text as="p" marginTop={2} color="textCriticalSubdued">
|
||||
{errors.root?.serverError.message}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
}) => (
|
||||
<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={props.onSubmit} defaultValues={defaultValues} />
|
||||
<ButtonsBox marginTop={8}>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onClick={() => {
|
||||
props.onClose();
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button variant="primary" type="submit" form={AddConnectionFormID}>
|
||||
Add connection
|
||||
</Button>
|
||||
</ButtonsBox>
|
||||
</Modal>
|
||||
);
|
||||
export const AddConnectionModal = (props: { onSuccess(): void; onClose(): void }) => {
|
||||
const { data: providers } = trpcClient.providersConfigs.getAll.useQuery();
|
||||
|
||||
if (!providers) {
|
||||
return <Skeleton.Section />;
|
||||
}
|
||||
|
||||
const { mutateAsync: addProviderMutate, isLoading } =
|
||||
trpcClient.channelsProvidersConnection.addConnection.useMutation({
|
||||
onSuccess() {
|
||||
props.onSuccess();
|
||||
},
|
||||
});
|
||||
|
||||
const handleFormSubmit = async (values: AddConnectionFormSchema) => {
|
||||
const providerType = providers.find((p) => p.id === values.providerId)?.type;
|
||||
|
||||
if (!providerType) {
|
||||
throw new Error("Provider not found");
|
||||
}
|
||||
|
||||
return addProviderMutate({
|
||||
...values,
|
||||
providerType,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal onClose={props.onClose}>
|
||||
<Text as="h2" variant="heading">
|
||||
Connect channel with Provider
|
||||
</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 { 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);
|
||||
}),
|
||||
|
|
|
@ -7,6 +7,7 @@ import { AddConnectionFormSchema } from "./add-connection-form";
|
|||
import { AddConnectionModal } from "./add-connection-modal";
|
||||
import { ChanelProviderConnectionsSectionHeader } from "./channel-provider-connections-section-header";
|
||||
import { ConnectionsList } from "./connections-list";
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
|
||||
const NoConnections = (props: { onCreate(): void; enabled: boolean }) => (
|
||||
<Box>
|
||||
|
@ -27,12 +28,13 @@ const NoConnections = (props: { onCreate(): void; enabled: boolean }) => (
|
|||
export const ChannelProviderConnectionList = () => {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
const { data: connectionsData, refetch } =
|
||||
const { data: connectionsData, refetch: refetchConnections } =
|
||||
trpcClient.channelsProvidersConnection.fetchConnections.useQuery();
|
||||
|
||||
const { mutate: removeConnection } =
|
||||
trpcClient.channelsProvidersConnection.removeConnection.useMutation({
|
||||
onSuccess() {
|
||||
refetch();
|
||||
refetchConnections();
|
||||
notifySuccess("Success", "Removed connection");
|
||||
},
|
||||
});
|
||||
|
@ -42,32 +44,10 @@ export const ChannelProviderConnectionList = () => {
|
|||
trpcClient.channelsProvidersConnection.fetchAllChannels.useQuery();
|
||||
const { data: providers } = trpcClient.providersConfigs.getAll.useQuery();
|
||||
|
||||
const { mutate: addProviderMutate } =
|
||||
trpcClient.channelsProvidersConnection.addConnection.useMutation({
|
||||
onSuccess() {
|
||||
notifySuccess("Success", "Added connection");
|
||||
refetch();
|
||||
setDialogOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
if (!providers) {
|
||||
return null;
|
||||
return <Skeleton.Section />;
|
||||
}
|
||||
|
||||
const handleFormSubmit = (values: AddConnectionFormSchema) => {
|
||||
const providerType = providers.find((p) => p.id === values.providerId)?.type;
|
||||
|
||||
if (!providerType) {
|
||||
throw new Error("Provider not found");
|
||||
}
|
||||
|
||||
addProviderMutate({
|
||||
...values,
|
||||
providerType,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (connectionId: string) => {
|
||||
removeConnection({ id: connectionId });
|
||||
};
|
||||
|
@ -83,7 +63,11 @@ export const ChannelProviderConnectionList = () => {
|
|||
onClose={() => {
|
||||
setDialogOpen(false);
|
||||
}}
|
||||
onSubmit={handleFormSubmit}
|
||||
onSuccess={() => {
|
||||
refetchConnections();
|
||||
notifySuccess("Success", "Connection created");
|
||||
setDialogOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{connectionsData.length === 0 && (
|
||||
|
|
|
@ -4,6 +4,7 @@ import React from "react";
|
|||
import { trpcClient } from "../trpc/trpc-client";
|
||||
import { ChanelProviderConnectionsSectionHeader } from "./channel-provider-connections-section-header";
|
||||
import { ProvidersResolver } from "../providers/providers-resolver";
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
|
||||
export const ConnectionsList = (props: { onRemove(connectionId: string): void }) => {
|
||||
const { data } = trpcClient.channelsProvidersConnection.fetchConnections.useQuery();
|
||||
|
@ -11,7 +12,7 @@ export const ConnectionsList = (props: { onRemove(connectionId: string): void })
|
|||
const { data: providers } = trpcClient.providersConfigs.getAll.useQuery();
|
||||
|
||||
if (!data || !providers) {
|
||||
return null;
|
||||
return <Skeleton.Section />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
@ -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([
|
||||
/**
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ import { ProvidersConfig } from "../configuration";
|
|||
import { ProvidersResolver } from "../providers/providers-resolver";
|
||||
import { trpcClient } from "../trpc/trpc-client";
|
||||
import { ButtonsBox } from "../ui/buttons-box";
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
|
||||
const ProvidersTable = (props: { providers: ProvidersConfig.AnyFullShape[] }) => {
|
||||
const { push } = useRouter();
|
||||
|
@ -46,7 +47,7 @@ export const ProvidersList = () => {
|
|||
const { push } = useRouter();
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
return <Skeleton.Section />;
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
|
|
|
@ -9,6 +9,7 @@ import { printSaleorProductFields } from "../../configuration/print-saleor-produ
|
|||
import { trpcClient } from "../../trpc/trpc-client";
|
||||
import { ButtonsBox } from "../../ui/buttons-box";
|
||||
import { TextLink } from "@saleor/apps-ui";
|
||||
import { Skeleton } from "@/modules/ui/skeleton";
|
||||
|
||||
type FormShape = Omit<BuilderIoProviderConfig.InputShape, "type">;
|
||||
const FormSchema = BuilderIoProviderConfig.Schema.Input.omit({ type: true });
|
||||
|
@ -20,7 +21,7 @@ type PureFormProps = {
|
|||
};
|
||||
|
||||
const PureForm = ({ defaultValues, onSubmit, onDelete }: PureFormProps) => {
|
||||
const { control, handleSubmit } = useForm<FormShape>({
|
||||
const { control, handleSubmit } = useForm({
|
||||
defaultValues: defaultValues,
|
||||
resolver: zodResolver(FormSchema),
|
||||
});
|
||||
|
@ -146,7 +147,7 @@ const AddFormVariant = () => {
|
|||
const { notifySuccess } = useDashboardNotification();
|
||||
const { mutate: addProvider } = trpcClient.providersConfigs.addOne.useMutation({
|
||||
onSuccess() {
|
||||
notifySuccess("Success", "Updated configuration");
|
||||
notifySuccess("Success", "Saved configuration");
|
||||
push("/configuration");
|
||||
},
|
||||
});
|
||||
|
@ -204,7 +205,7 @@ const EditFormVariant = (props: { configId: string }) => {
|
|||
});
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
return <Skeleton.Section />;
|
||||
}
|
||||
|
||||
if (data.type !== "builder.io") {
|
||||
|
@ -230,10 +231,6 @@ const EditFormVariant = (props: { configId: string }) => {
|
|||
);
|
||||
};
|
||||
|
||||
/*
|
||||
* todo make the same with contentful
|
||||
* todo improve copy
|
||||
*/
|
||||
export const BuilderIoConfigForm = {
|
||||
PureVariant: PureForm,
|
||||
AddVariant: AddFormVariant,
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -17,7 +17,6 @@ type FormSchema = Omit<ContentfulProviderConfig.InputShape, "type">;
|
|||
|
||||
/**
|
||||
* TODO - when space, token or env changes, refetch queries
|
||||
* TODO - error handling
|
||||
* TODO - refactor smaller hooks
|
||||
*/
|
||||
const PureForm = ({
|
||||
|
@ -29,14 +28,9 @@ const PureForm = ({
|
|||
onSubmit(values: FormSchema): void;
|
||||
onDelete?(): void;
|
||||
}) => {
|
||||
const {
|
||||
control,
|
||||
getValues,
|
||||
setValue,
|
||||
watch,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<FormSchema>({
|
||||
const { notifyError } = useDashboardNotification();
|
||||
|
||||
const { control, getValues, setValue, watch, handleSubmit, setError, clearErrors } = useForm({
|
||||
defaultValues: defaultValues,
|
||||
resolver: zodResolver(ContentfulProviderConfig.Schema.Input.omit({ type: true })),
|
||||
});
|
||||
|
@ -45,6 +39,22 @@ const PureForm = ({
|
|||
trpcClient.contentful.fetchContentTypesFromApi.useMutation({
|
||||
onSuccess(data) {
|
||||
setValue("contentId", data.items[0].sys.id ?? null);
|
||||
|
||||
clearErrors(["authToken", "spaceId"]);
|
||||
},
|
||||
onError() {
|
||||
setError("authToken", {
|
||||
type: "custom",
|
||||
message: "Invalid credentials",
|
||||
});
|
||||
setError("spaceId", {
|
||||
type: "custom",
|
||||
message: "Invalid credentials",
|
||||
});
|
||||
notifyError(
|
||||
"Error",
|
||||
"Could not fetch content types from Contentful. Please check your credentials."
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -52,6 +62,22 @@ const PureForm = ({
|
|||
trpcClient.contentful.fetchEnvironmentsFromApi.useMutation({
|
||||
onSuccess(data) {
|
||||
setValue("environment", data.items[0].sys.id);
|
||||
|
||||
clearErrors(["authToken", "spaceId"]);
|
||||
},
|
||||
onError() {
|
||||
setError("authToken", {
|
||||
type: "custom",
|
||||
message: "Invalid credentials",
|
||||
});
|
||||
setError("spaceId", {
|
||||
type: "custom",
|
||||
message: "Invalid credentials",
|
||||
});
|
||||
notifyError(
|
||||
"Error",
|
||||
"Could not fetch environments from Contentful. Please check your credentials."
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -289,7 +315,7 @@ const AddVariant = () => {
|
|||
|
||||
const { mutate } = trpcClient.providersConfigs.addOne.useMutation({
|
||||
onSuccess() {
|
||||
notifySuccess("Success", "Updated configuration");
|
||||
notifySuccess("Success", "Saved configuration");
|
||||
push("/configuration");
|
||||
},
|
||||
});
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { createLogger } from "@saleor/apps-shared";
|
||||
import {
|
||||
WebhookProductFragment,
|
||||
WebhookProductVariantFragment,
|
||||
|
@ -17,6 +18,7 @@ export type ContentfulClientFactory = (
|
|||
|
||||
export class ContentfulWebhooksProcessor implements ProductWebhooksProcessor {
|
||||
private client: ContentfulClientStrip;
|
||||
private logger = createLogger({ name: "ContentfulWebhooksProcessor" });
|
||||
|
||||
constructor(
|
||||
private providerConfig: ContentfulProviderConfig.FullShape,
|
||||
|
@ -30,18 +32,24 @@ export class ContentfulWebhooksProcessor implements ProductWebhooksProcessor {
|
|||
}
|
||||
|
||||
async onProductVariantUpdated(productVariant: WebhookProductVariantFragment): Promise<void> {
|
||||
this.logger.trace("onProductVariantUpdated called");
|
||||
|
||||
await this.client.upsertProductVariant({
|
||||
configuration: this.providerConfig,
|
||||
variant: productVariant,
|
||||
});
|
||||
}
|
||||
async onProductVariantCreated(productVariant: WebhookProductVariantFragment): Promise<void> {
|
||||
this.logger.trace("onProductVariantCreated called");
|
||||
|
||||
await this.client.upsertProductVariant({
|
||||
configuration: this.providerConfig,
|
||||
variant: productVariant,
|
||||
});
|
||||
}
|
||||
async onProductVariantDeleted(productVariant: WebhookProductVariantFragment): Promise<void> {
|
||||
this.logger.trace("onProductVariantDeleted called");
|
||||
|
||||
await this.client.deleteProductVariant({
|
||||
configuration: this.providerConfig,
|
||||
variant: productVariant,
|
||||
|
@ -49,6 +57,8 @@ export class ContentfulWebhooksProcessor implements ProductWebhooksProcessor {
|
|||
}
|
||||
|
||||
async onProductUpdated(product: WebhookProductFragment): Promise<void> {
|
||||
this.logger.trace("onProductUpdated called");
|
||||
|
||||
await Promise.all(
|
||||
(product.variants ?? []).map((variant) => {
|
||||
return this.client.upsertProductVariant({
|
||||
|
|
|
@ -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" });
|
||||
});
|
||||
|
|
|
@ -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) {}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -19,12 +19,52 @@ type PureFormProps = {
|
|||
onDelete?(): void;
|
||||
};
|
||||
|
||||
const useDatoCmsRemoteFields = () => {
|
||||
/*
|
||||
* todo react on token change, refresh mutation
|
||||
*/
|
||||
const PureForm = ({ defaultValues, onSubmit, onDelete }: PureFormProps) => {
|
||||
const { notifyError } = useDashboardNotification();
|
||||
|
||||
const { control, getValues, setValue, watch, handleSubmit, clearErrors, setError } = useForm({
|
||||
defaultValues: defaultValues,
|
||||
resolver: zodResolver(DatocmsProviderConfig.Schema.Input.omit({ type: true })),
|
||||
});
|
||||
|
||||
const { mutate: fetchContentTypes, data: contentTypesData } =
|
||||
trpcClient.datocms.fetchContentTypes.useMutation();
|
||||
trpcClient.datocms.fetchContentTypes.useMutation({
|
||||
onSuccess(data) {
|
||||
clearErrors("authToken");
|
||||
},
|
||||
onError() {
|
||||
setError("authToken", {
|
||||
type: "custom",
|
||||
message: "Invalid credentials",
|
||||
});
|
||||
|
||||
notifyError(
|
||||
"Error",
|
||||
"Could not fetch content types from DatoCMS. Please check your credentials."
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: fetchContentTypeFields, data: fieldsData } =
|
||||
trpcClient.datocms.fetchContentTypeFields.useMutation();
|
||||
trpcClient.datocms.fetchContentTypeFields.useMutation({
|
||||
onSuccess(data) {
|
||||
clearErrors("authToken");
|
||||
},
|
||||
onError() {
|
||||
setError("authToken", {
|
||||
type: "custom",
|
||||
message: "Invalid credentials",
|
||||
});
|
||||
|
||||
notifyError(
|
||||
"Error",
|
||||
"Could not fetch content types from DatoCMS. Please check your credentials."
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const contentTypesSelectOptions = useMemo(() => {
|
||||
if (!contentTypesData) {
|
||||
|
@ -37,40 +77,6 @@ const useDatoCmsRemoteFields = () => {
|
|||
}));
|
||||
}, [contentTypesData]);
|
||||
|
||||
return {
|
||||
fetchContentTypes,
|
||||
contentTypesData,
|
||||
contentTypesSelectOptions,
|
||||
fetchContentTypeFields,
|
||||
fieldsData,
|
||||
};
|
||||
};
|
||||
|
||||
/*
|
||||
* todo react on token error
|
||||
* todo react on token change, refresh mutation
|
||||
*/
|
||||
const PureForm = ({ defaultValues, onSubmit, onDelete }: PureFormProps) => {
|
||||
const {
|
||||
control,
|
||||
getValues,
|
||||
setValue,
|
||||
watch,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<FormShape>({
|
||||
defaultValues: defaultValues,
|
||||
resolver: zodResolver(DatocmsProviderConfig.Schema.Input.omit({ type: true })),
|
||||
});
|
||||
|
||||
const {
|
||||
contentTypesData,
|
||||
fetchContentTypes,
|
||||
contentTypesSelectOptions,
|
||||
fetchContentTypeFields,
|
||||
fieldsData,
|
||||
} = useDatoCmsRemoteFields();
|
||||
|
||||
const selectedContentType = watch("itemType");
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -225,7 +231,7 @@ const AddFormVariant = () => {
|
|||
const { notifySuccess } = useDashboardNotification();
|
||||
const { mutate: addProvider } = trpcClient.providersConfigs.addOne.useMutation({
|
||||
onSuccess() {
|
||||
notifySuccess("Success", "Updated configuration");
|
||||
notifySuccess("Success", "Saved configuration");
|
||||
push("/configuration");
|
||||
},
|
||||
});
|
||||
|
|
|
@ -6,12 +6,14 @@ import {
|
|||
|
||||
import { ProductWebhooksProcessor } from "../../webhooks-operations/product-webhooks-processor";
|
||||
import { DatoCMSClient } from "./datocms-client";
|
||||
import { createLogger } from "@saleor/apps-shared";
|
||||
|
||||
/*
|
||||
* todo error handling
|
||||
*/
|
||||
export class DatocmsWebhooksProcessor implements ProductWebhooksProcessor {
|
||||
private client: DatoCMSClient;
|
||||
private logger = createLogger({ name: "DatocmsWebhooksProcessor" });
|
||||
|
||||
constructor(private providerConfig: DatocmsProviderConfig.FullShape) {
|
||||
this.client = new DatoCMSClient({
|
||||
|
@ -20,18 +22,25 @@ export class DatocmsWebhooksProcessor implements ProductWebhooksProcessor {
|
|||
}
|
||||
|
||||
async onProductVariantUpdated(productVariant: WebhookProductVariantFragment): Promise<void> {
|
||||
this.logger.trace("onProductVariantUpdated called");
|
||||
|
||||
await this.client.updateProductVariant({
|
||||
configuration: this.providerConfig,
|
||||
variant: productVariant,
|
||||
});
|
||||
}
|
||||
|
||||
async onProductVariantCreated(productVariant: WebhookProductVariantFragment): Promise<void> {
|
||||
this.logger.trace("onProductVariantCreated called");
|
||||
|
||||
await this.client.uploadProductVariant({
|
||||
configuration: this.providerConfig,
|
||||
variant: productVariant,
|
||||
});
|
||||
}
|
||||
async onProductVariantDeleted(productVariant: WebhookProductVariantFragment): Promise<void> {
|
||||
this.logger.trace("onProductVariantDeleted called");
|
||||
|
||||
await this.client.deleteProductVariant({
|
||||
configuration: this.providerConfig,
|
||||
variant: productVariant,
|
||||
|
@ -39,6 +48,8 @@ export class DatocmsWebhooksProcessor implements ProductWebhooksProcessor {
|
|||
}
|
||||
|
||||
async onProductUpdated(product: WebhookProductFragment): Promise<void> {
|
||||
this.logger.trace("onProductUpdated called");
|
||||
|
||||
await Promise.all(
|
||||
(product.variants ?? []).map((variant) => {
|
||||
return this.client.upsertProduct({
|
||||
|
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
|
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 { 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({
|
||||
|
|
|
@ -8,6 +8,7 @@ import { SaleorProviderFieldsMappingKeys, StrapiProviderConfig } from "../../con
|
|||
import { printSaleorProductFields } from "../../configuration/print-saleor-product-fields";
|
||||
import { trpcClient } from "../../trpc/trpc-client";
|
||||
import { ButtonsBox } from "../../ui/buttons-box";
|
||||
import { Skeleton } from "@/modules/ui/skeleton";
|
||||
|
||||
type FormShape = Omit<StrapiProviderConfig.InputShape, "type">;
|
||||
|
||||
|
@ -18,14 +19,7 @@ type PureFormProps = {
|
|||
};
|
||||
|
||||
const PureForm = ({ defaultValues, onSubmit, onDelete }: PureFormProps) => {
|
||||
const {
|
||||
control,
|
||||
getValues,
|
||||
setValue,
|
||||
watch,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<FormShape>({
|
||||
const { control, handleSubmit } = useForm({
|
||||
defaultValues: defaultValues,
|
||||
resolver: zodResolver(StrapiProviderConfig.Schema.Input.omit({ type: true })),
|
||||
});
|
||||
|
@ -137,7 +131,7 @@ const AddFormVariant = () => {
|
|||
const { notifySuccess } = useDashboardNotification();
|
||||
const { mutate: addProvider } = trpcClient.providersConfigs.addOne.useMutation({
|
||||
onSuccess() {
|
||||
notifySuccess("Success", "Updated configuration");
|
||||
notifySuccess("Success", "Saved configuration");
|
||||
push("/configuration");
|
||||
},
|
||||
});
|
||||
|
@ -195,7 +189,7 @@ const EditFormVariant = (props: { configId: string }) => {
|
|||
});
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
return <Skeleton.Section />;
|
||||
}
|
||||
|
||||
if (data.type !== "strapi") {
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { createLogger } from "@saleor/apps-shared";
|
||||
import {
|
||||
WebhookProductFragment,
|
||||
WebhookProductVariantFragment,
|
||||
|
@ -8,22 +9,31 @@ import { StrapiClient } from "./strapi-client";
|
|||
|
||||
export class StrapiWebhooksProcessor implements ProductWebhooksProcessor {
|
||||
private client: StrapiClient;
|
||||
private logger = createLogger({ name: "StrapiWebhooksProcessor" });
|
||||
|
||||
constructor(private config: StrapiProviderConfig.FullShape) {
|
||||
this.client = new StrapiClient({ url: config.url, token: config.authToken });
|
||||
}
|
||||
|
||||
async onProductVariantUpdated(productVariant: WebhookProductVariantFragment): Promise<void> {
|
||||
this.logger.trace("onProductVariantUpdated called");
|
||||
|
||||
this.client.updateProduct({ configuration: this.config, variant: productVariant });
|
||||
}
|
||||
async onProductVariantCreated(productVariant: WebhookProductVariantFragment): Promise<void> {
|
||||
this.logger.trace("onProductVariantCreated called");
|
||||
|
||||
this.client.uploadProduct({ configuration: this.config, variant: productVariant });
|
||||
}
|
||||
async onProductVariantDeleted(productVariant: WebhookProductVariantFragment): Promise<void> {
|
||||
this.logger.trace("onProductVariantDeleted called");
|
||||
|
||||
this.client.deleteProduct({ configuration: this.config, variant: productVariant });
|
||||
}
|
||||
|
||||
async onProductUpdated(product: WebhookProductFragment): Promise<void> {
|
||||
this.logger.trace("onProductUpdated called");
|
||||
|
||||
await Promise.all(
|
||||
(product.variants ?? []).map((variant) => {
|
||||
return this.client.upsertProduct({
|
||||
|
|
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) {
|
||||
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(
|
||||
|
|
|
@ -4,6 +4,7 @@ import { NextPage } from "next";
|
|||
import { useRouter } from "next/router";
|
||||
import { z } from "zod";
|
||||
import { Text } from "@saleor/macaw-ui/next";
|
||||
import { Skeleton } from "@/modules/ui/skeleton";
|
||||
|
||||
const BulkSyncPage: NextPage = () => {
|
||||
const { query } = useRouter();
|
||||
|
@ -44,7 +45,7 @@ const BulkSyncPage: NextPage = () => {
|
|||
}
|
||||
|
||||
if (connectionLoading || providerLoading) {
|
||||
return <Text>Loading...</Text>;
|
||||
return <Skeleton.Section />;
|
||||
}
|
||||
|
||||
if (!(provider && connection)) {
|
||||
|
|
|
@ -37,15 +37,11 @@ const ConfigurationPage: NextPage = () => {
|
|||
heading="Initial sync"
|
||||
sideContent={
|
||||
<Box>
|
||||
<Text as="p">
|
||||
<Text as="p" marginBottom={2}>
|
||||
Perform an initial scan of product variants to push existing database to selected
|
||||
provider.
|
||||
</Text>
|
||||
<Text as="p">Its recommended to run this flow initially, once app is configured.</Text>
|
||||
<Text as="p" color="iconCriticalDefault">
|
||||
Warning: removing products in Saleor will not remove them in CMS. To create a fully
|
||||
updated.
|
||||
</Text>
|
||||
</Box>
|
||||
}
|
||||
mainContent={<BulkSyncSection />}
|
||||
|
|
|
@ -3,6 +3,7 @@ import { ProvidersResolver } from "@/modules/providers/providers-resolver";
|
|||
import { trpcClient } from "@/modules/trpc/trpc-client";
|
||||
import { AppHeader } from "@/modules/ui/app-header";
|
||||
import { AppSection } from "@/modules/ui/app-section";
|
||||
import { Skeleton } from "@/modules/ui/skeleton";
|
||||
import { Breadcrumbs } from "@saleor/apps-ui";
|
||||
import { Box, Text } from "@saleor/macaw-ui/next";
|
||||
import { NextPage } from "next";
|
||||
|
@ -27,7 +28,7 @@ const EditProviderPage: NextPage = () => {
|
|||
}, [data]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Text>Loading...</Text>;
|
||||
return <Skeleton.Section />;
|
||||
}
|
||||
|
||||
if (isFetched && !data) {
|
||||
|
@ -37,7 +38,7 @@ const EditProviderPage: NextPage = () => {
|
|||
}
|
||||
|
||||
if (!provider) {
|
||||
return null;
|
||||
return <Skeleton.Section />;
|
||||
}
|
||||
|
||||
const EditForm = ProvidersResolver.getEditProviderFormComponent(provider.type);
|
||||
|
|
Loading…
Reference in a new issue