Remove channels config

This commit is contained in:
Krzysztof Wolski 2023-04-19 18:13:33 +02:00
parent 8e0b08523b
commit 55b998a75f
26 changed files with 144 additions and 1068 deletions

View file

@ -0,0 +1,27 @@
interface IsAvailableInChannelArgs {
channel: string;
restrictedToChannels: string[];
excludedChannels: string[];
}
/**
* Returns true if the channel is available for the configuration.
*
* Is available if:
* - it's not in the excluded list
* - if assigned list is not empty, it's in the assigned list
* - assigned list is empty
*/
export const isAvailableInChannel = ({
channel,
restrictedToChannels,
excludedChannels,
}: IsAvailableInChannelArgs): boolean => {
if (channel in excludedChannels) {
return false;
}
if (restrictedToChannels.length > 0 && !(channel in restrictedToChannels)) {
return false;
}
return true;
};

View file

@ -1,33 +0,0 @@
import { AppConfig, AppConfigurationPerChannel } from "./app-config";
export const getDefaultEmptyAppConfiguration = (): AppConfigurationPerChannel => ({
active: false,
mjmlConfigurationId: undefined,
sendgridConfigurationId: undefined,
});
const getChannelAppConfiguration =
(appConfig: AppConfig | null | undefined) => (channelSlug: string) => {
try {
return appConfig?.configurationsPerChannel[channelSlug] ?? null;
} catch (e) {
return null;
}
};
const setChannelAppConfiguration =
(appConfig: AppConfig | null | undefined) =>
(channelSlug: string) =>
(appConfiguration: AppConfigurationPerChannel) => {
const appConfigNormalized = structuredClone(appConfig) ?? { configurationsPerChannel: {} };
appConfigNormalized.configurationsPerChannel[channelSlug] ??= getDefaultEmptyAppConfiguration();
appConfigNormalized.configurationsPerChannel[channelSlug] = appConfiguration;
return appConfigNormalized;
};
export const AppConfigContainer = {
getChannelAppConfiguration,
setChannelAppConfiguration,
};

View file

@ -1,20 +0,0 @@
import { z } from "zod";
export const appConfigInputSchema = z.object({
configurationsPerChannel: z.record(
z.object({
active: z.boolean(),
mjmlConfigurationId: z.string().optional(),
sendgridConfigurationId: z.string().optional(),
})
),
});
export const appChannelConfigurationInputSchema = z.object({
channel: z.string(),
configuration: z.object({
active: z.boolean(),
mjmlConfigurationId: z.string().optional(),
sendgridConfigurationId: z.string().optional(),
}),
});

View file

@ -1,11 +0,0 @@
export interface AppConfigurationPerChannel {
active: boolean;
mjmlConfigurationId?: string;
sendgridConfigurationId?: string;
}
export type AppConfigurationsChannelMap = Record<string, AppConfigurationPerChannel>;
export type AppConfig = {
configurationsPerChannel: AppConfigurationsChannelMap;
};

View file

@ -1,70 +0,0 @@
import { createLogger } from "@saleor/apps-shared";
import {
appChannelConfigurationInputSchema,
appConfigInputSchema,
} from "./app-config-input-schema";
import { AppConfigurationService } from "./get-app-configuration.service";
import { router } from "../trpc/trpc-server";
import { protectedClientProcedure } from "../trpc/protected-client-procedure";
import { z } from "zod";
/*
* Allow access only for the dashboard users and attaches the
* configuration service to the context
*/
const protectedWithConfigurationService = protectedClientProcedure.use(({ next, ctx }) =>
next({
ctx: {
...ctx,
configurationService: new AppConfigurationService({
apiClient: ctx.apiClient,
saleorApiUrl: ctx.saleorApiUrl,
}),
},
})
);
export const appConfigurationRouter = router({
getChannelConfiguration: protectedWithConfigurationService
.input(z.object({ channelSlug: z.string() }))
.query(async ({ ctx, input }) => {
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug("Get Channel Configuration called");
return await ctx.configurationService.getChannelConfiguration(input.channelSlug);
}),
setChannelConfiguration: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(appChannelConfigurationInputSchema)
.mutation(async ({ ctx, input }) => {
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug("Set channel configuration called");
await ctx.configurationService.setChannelConfiguration(input);
}),
fetch: protectedWithConfigurationService.query(async ({ ctx, input }) => {
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug("appConfigurationRouter.fetch called");
return new AppConfigurationService({
apiClient: ctx.apiClient,
saleorApiUrl: ctx.saleorApiUrl,
}).getConfiguration();
}),
setAndReplace: protectedWithConfigurationService
.meta({ requiredClientPermissions: ["MANAGE_APPS"] })
.input(appConfigInputSchema)
.mutation(async ({ ctx, input }) => {
const logger = createLogger({ saleorApiUrl: ctx.saleorApiUrl });
logger.debug(input, "appConfigurationRouter.setAndReplace called with input");
await ctx.configurationService.setConfigurationRoot(input);
return null;
}),
});

View file

@ -1,35 +0,0 @@
import { AppConfig } from "./app-config";
import { SettingsManager } from "@saleor/app-sdk/settings-manager";
export interface AppConfigurator {
setConfig(config: AppConfig): Promise<void>;
getConfig(): Promise<AppConfig | undefined>;
}
export class PrivateMetadataAppConfigurator implements AppConfigurator {
private metadataKey = "app-config";
constructor(private metadataManager: SettingsManager, private saleorApiUrl: string) {}
getConfig(): Promise<AppConfig | undefined> {
return this.metadataManager.get(this.metadataKey, this.saleorApiUrl).then((data) => {
if (!data) {
return data;
}
try {
return JSON.parse(data);
} catch (e) {
throw new Error("Invalid metadata value, cant be parsed");
}
});
}
setConfig(config: AppConfig): Promise<void> {
return this.metadataManager.set({
key: this.metadataKey,
value: JSON.stringify(config),
domain: this.saleorApiUrl,
});
}
}

View file

@ -1,22 +0,0 @@
import { AppConfig } from "./app-config";
import { AppConfigContainer, getDefaultEmptyAppConfiguration } from "./app-config-container";
import { ChannelFragment, ShopInfoFragment } from "../../../generated/graphql";
/**
* TODO Test
*/
export const FallbackAppConfig = {
createFallbackConfigFromExistingShopAndChannels(
channels: ChannelFragment[],
shopAppConfiguration: ShopInfoFragment | null
) {
return (channels ?? []).reduce<AppConfig>(
(state, channel) => {
return AppConfigContainer.setChannelAppConfiguration(state)(channel.slug)(
getDefaultEmptyAppConfiguration()
);
},
{ configurationsPerChannel: {} }
);
},
};

View file

@ -1,95 +0,0 @@
import { PrivateMetadataAppConfigurator } from "./app-configurator";
import { Client } from "urql";
import { createLogger } from "@saleor/apps-shared";
import { AppConfig, AppConfigurationPerChannel } from "./app-config";
import { getDefaultEmptyAppConfiguration } from "./app-config-container";
import { createSettingsManager } from "../../lib/metadata-manager";
const logger = createLogger({
service: "AppConfigurationService",
});
export class AppConfigurationService {
private configurationData?: AppConfig;
private metadataConfigurator: PrivateMetadataAppConfigurator;
constructor(args: { apiClient: Client; saleorApiUrl: string; initialData?: AppConfig }) {
this.metadataConfigurator = new PrivateMetadataAppConfigurator(
createSettingsManager(args.apiClient),
args.saleorApiUrl
);
}
// Fetch configuration from Saleor API and cache it
private async pullConfiguration() {
logger.debug("Fetch configuration from Saleor API");
const config = await this.metadataConfigurator.getConfig();
this.configurationData = config;
}
// Push configuration to Saleor API
private async pushConfiguration() {
logger.debug("Push configuration to Saleor API");
await this.metadataConfigurator.setConfig(this.configurationData!);
}
async getConfiguration() {
logger.debug("Get configuration");
if (!this.configurationData) {
logger.debug("No configuration found in cache. Will fetch it from Saleor API");
await this.pullConfiguration();
}
const savedAppConfig = this.configurationData ?? null;
logger.debug(savedAppConfig, "Retrieved app config from Metadata. Will return it");
if (savedAppConfig) {
return savedAppConfig;
}
}
// Saves configuration to Saleor API and cache it
async setConfigurationRoot(config: AppConfig) {
logger.debug("Set configuration");
this.configurationData = config;
await this.pushConfiguration();
}
// Returns channel configuration if existing. Otherwise returns default empty one
async getChannelConfiguration(channel: string) {
logger.debug("Get channel configuration");
const configurations = await this.getConfiguration();
if (!configurations) {
return getDefaultEmptyAppConfiguration();
}
const channelConfiguration = configurations.configurationsPerChannel[channel];
return channelConfiguration || getDefaultEmptyAppConfiguration();
}
async setChannelConfiguration({
channel,
configuration,
}: {
channel: string;
configuration: AppConfigurationPerChannel;
}) {
logger.debug("Set channel configuration");
let configurations = await this.getConfiguration();
if (!configurations) {
configurations = { configurationsPerChannel: {} };
}
configurations.configurationsPerChannel[channel] = configuration;
await this.setConfigurationRoot(configurations);
}
}

View file

@ -1,181 +0,0 @@
import { AppConfigurationPerChannel } from "../app-config";
import { Controller, useForm } from "react-hook-form";
import { FormControl, InputLabel, Link, MenuItem, Select, Typography } from "@material-ui/core";
import { Button, makeStyles, SwitchSelector, SwitchSelectorButton } from "@saleor/macaw-ui";
import React, { useEffect } from "react";
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
import { useRouter } from "next/router";
import { mjmlUrls } from "../../mjml/urls";
const useStyles = makeStyles((theme) => ({
field: {
marginBottom: 20,
},
channelName: {
cursor: "pointer",
borderBottom: `2px solid ${theme.palette.secondary.main}`,
},
}));
type AppConfigurationFormProps = {
channelSlug: string;
channelName: string;
channelID: string;
mjmlConfigurationChoices: { label: string; value: string }[];
sendgridConfigurationChoices: { label: string; value: string }[];
onSubmit(data: AppConfigurationPerChannel): Promise<void>;
initialData?: AppConfigurationPerChannel | null;
};
export const AppConfigurationForm = (props: AppConfigurationFormProps) => {
const styles = useStyles();
const { appBridge } = useAppBridge();
const router = useRouter();
const { handleSubmit, getValues, setValue, control, reset } = useForm<AppConfigurationPerChannel>(
{
defaultValues: props.initialData ?? undefined,
}
);
useEffect(() => {
reset(props.initialData || undefined);
}, [props.initialData, reset]);
const handleChannelNameClick = () => {
appBridge?.dispatch(
actions.Redirect({
to: `/channels/${props.channelID}`,
})
);
};
const isNoSendgridConfigurations = !props.sendgridConfigurationChoices.length;
const isNoMjmlConfigurations = !props.mjmlConfigurationChoices.length;
return (
<form
onSubmit={handleSubmit((data, event) => {
props.onSubmit(data);
})}
>
<Typography variant="h2" paragraph>
Configure
<span onClick={handleChannelNameClick} className={styles.channelName}>
{` ${props.channelName} `}
</span>
channel:
</Typography>
<Controller
control={control}
name="active"
render={({ field: { value, name, onChange } }) => (
<div className={styles.field}>
{/* TODO: fix types in the MacawUI */}
{/* @ts-ignore: MacawUI use wrong type for */}
<SwitchSelector key={name} className={styles.field}>
{[
{ label: "Active", value: true },
{ label: "Disabled", value: false },
].map((button) => (
// @ts-ignore: MacawUI use wrong type for SwitchSelectorButton
<SwitchSelectorButton
value={button.value.toString()}
onClick={() => onChange(button.value)}
activeTab={value?.toString() || "false"}
key={button.label}
>
{button.label}
</SwitchSelectorButton>
))}
</SwitchSelector>
</div>
)}
/>
<Controller
control={control}
name="mjmlConfigurationId"
render={({ field: { value, onChange } }) => {
return (
<FormControl disabled={isNoMjmlConfigurations} className={styles.field} fullWidth>
<InputLabel>MJML Configuration</InputLabel>
<Select
variant="outlined"
value={value}
onChange={(event, val) => {
onChange(event.target.value);
}}
>
<MenuItem key="none" value={undefined}>
No configuration
</MenuItem>
{props.mjmlConfigurationChoices.map((choice) => (
<MenuItem key={choice.value} value={choice.value}>
{choice.label}
</MenuItem>
))}
</Select>
{isNoMjmlConfigurations && (
<Link
href="#"
onClick={() => {
router.push(mjmlUrls.configuration());
}}
>
<Typography variant="caption" color="textSecondary">
Currently theres no MJML configuration available. Click here to create one.
</Typography>
</Link>
)}
</FormControl>
);
}}
/>
<Controller
control={control}
name="sendgridConfigurationId"
render={({ field: { value, onChange } }) => {
return (
<FormControl disabled={isNoSendgridConfigurations} className={styles.field} fullWidth>
<InputLabel>Sendgrid Configuration</InputLabel>
<Select
variant="outlined"
value={value}
onChange={(event, val) => {
onChange(event.target.value);
}}
>
<MenuItem key="none" value={undefined}>
No configuration
</MenuItem>
{props.sendgridConfigurationChoices.map((choice) => (
<MenuItem key={choice.value} value={choice.value}>
{choice.label}
</MenuItem>
))}
</Select>
{isNoSendgridConfigurations && (
<Link
href="#"
onClick={() => {
router.push("");
}}
>
<Typography variant="caption" color="textSecondary">
Currently theres no Sendgrid configuration available. Click here to create one.
</Typography>
</Link>
)}
</FormControl>
);
}}
/>
<Button type="submit" fullWidth variant="primary">
Save configuration
</Button>
</form>
);
};

View file

@ -1,146 +0,0 @@
import React, { useMemo, useState } from "react";
import { EditIcon, IconButton, makeStyles } from "@saleor/macaw-ui";
import { AppConfigurationForm } from "./app-configuration-form";
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
import { AppColumnsLayout } from "../../ui/app-columns-layout";
import { trpcClient } from "../../trpc/trpc-client";
import { SideMenu } from "./side-menu";
import { LoadingIndicator } from "../../ui/loading-indicator";
import { Instructions } from "./instructions";
import { useDashboardNotification } from "@saleor/apps-shared";
const useStyles = makeStyles((theme) => {
return {
formContainer: {
top: 0,
},
configurationColumn: {
display: "flex",
flexDirection: "column",
gap: 20,
maxWidth: 700,
},
};
});
export const ChannelsConfigurationTab = () => {
const styles = useStyles();
const { appBridge } = useAppBridge();
const [activeChannelSlug, setActiveChannelSlug] = useState<string | null>(null);
const { notifySuccess } = useDashboardNotification();
const { data: channelsData, isLoading: isChannelsDataLoading } =
trpcClient.channels.fetch.useQuery(undefined, {
onSuccess: (data) => {
if (data?.length) {
setActiveChannelSlug(data[0].slug);
}
},
});
const {
data: configurationData,
refetch: refetchConfig,
isLoading: isConfigurationDataLoading,
} = trpcClient.appConfiguration.getChannelConfiguration.useQuery(
{
channelSlug: activeChannelSlug!,
},
{ enabled: !!activeChannelSlug }
);
const { data: mjmlConfigurations, isLoading: isMjmlQueryLoading } =
trpcClient.mjmlConfiguration.getConfigurations.useQuery({});
const mjmlConfigurationsListData = useMemo(() => {
return (
mjmlConfigurations?.map((configuration) => ({
value: configuration.id,
label: configuration.configurationName,
})) ?? []
);
}, [mjmlConfigurations]);
const { data: sendgridConfigurations, isLoading: isSendgridQueryLoading } =
trpcClient.sendgridConfiguration.getConfigurations.useQuery({});
const sendgridConfigurationsListData = useMemo(() => {
return (
sendgridConfigurations?.map((configuration) => ({
value: configuration.id,
label: configuration.configurationName,
})) ?? []
);
}, [sendgridConfigurations]);
const { mutate: mutateAppChannelConfiguration, error: saveError } =
trpcClient.appConfiguration.setChannelConfiguration.useMutation({
onSuccess() {
refetchConfig();
notifySuccess("Success", "Saved app configuration");
},
});
const activeChannel = channelsData?.find((c) => c.slug === activeChannelSlug);
if (isChannelsDataLoading) {
return <LoadingIndicator />;
}
if (!channelsData?.length) {
return <div>NO CHANNELS</div>;
}
const isFormDataLoading =
isConfigurationDataLoading || isMjmlQueryLoading || isSendgridQueryLoading;
return (
<AppColumnsLayout>
<SideMenu
title="Channels"
selectedItemId={activeChannel?.slug}
headerToolbar={
<IconButton
variant="secondary"
onClick={() => {
appBridge?.dispatch(
actions.Redirect({
to: `/channels/`,
})
);
}}
>
<EditIcon />
</IconButton>
}
onClick={(id) => setActiveChannelSlug(id)}
items={channelsData.map((c) => ({ label: c.name, id: c.slug })) || []}
/>
<div className={styles.configurationColumn}>
{!activeChannel || isFormDataLoading ? (
<LoadingIndicator />
) : (
<>
<AppConfigurationForm
channelID={activeChannel.id}
key={activeChannelSlug}
channelSlug={activeChannel.slug}
mjmlConfigurationChoices={mjmlConfigurationsListData}
sendgridConfigurationChoices={sendgridConfigurationsListData}
onSubmit={async (data) => {
mutateAppChannelConfiguration({
channel: activeChannel.slug,
configuration: data,
});
}}
initialData={configurationData}
channelName={activeChannel?.name ?? activeChannelSlug}
/>
{saveError && <span>{saveError.message}</span>}
</>
)}
</div>
<Instructions />
</AppColumnsLayout>
);
};

View file

@ -1,72 +0,0 @@
import {
makeStyles,
OffsettedList,
OffsettedListBody,
OffsettedListHeader,
OffsettedListItem,
OffsettedListItemCell,
} from "@saleor/macaw-ui";
import clsx from "clsx";
import { Typography } from "@material-ui/core";
import React from "react";
import { ChannelFragment } from "../../../../generated/graphql";
const useStyles = makeStyles((theme) => {
return {
listItem: {
cursor: "pointer",
height: "auto !important",
},
listItemActive: {
background: "#f4f4f4",
borderRadius: 4,
overflow: "hidden",
},
channelSlug: {
fontFamily: "monospace",
opacity: 0.8,
},
};
});
type Props = {
channels: ChannelFragment[];
activeChannelSlug: string;
onChannelClick(channelSlug: string): void;
};
export const ChannelsList = ({ channels, activeChannelSlug, onChannelClick }: Props) => {
const styles = useStyles();
return (
<OffsettedList gridTemplate={["1fr"]}>
<OffsettedListHeader>
<Typography variant="h3" paragraph>
Available channels
</Typography>
</OffsettedListHeader>
<OffsettedListBody>
{channels.map((c) => {
return (
<OffsettedListItem
className={clsx(styles.listItem, {
[styles.listItemActive]: c.slug === activeChannelSlug,
})}
key={c.slug}
onClick={() => {
onChannelClick(c.slug);
}}
>
<OffsettedListItemCell>
{c.name}
<Typography variant="caption" className={styles.channelSlug}>
{c.slug}
</Typography>
</OffsettedListItemCell>
</OffsettedListItem>
);
})}
</OffsettedListBody>
</OffsettedList>
);
};

View file

@ -1,87 +0,0 @@
import {
DeleteIcon,
IconButton,
makeStyles,
OffsettedList,
OffsettedListBody,
OffsettedListItem,
OffsettedListItemCell,
} from "@saleor/macaw-ui";
import clsx from "clsx";
import React from "react";
const useStyles = makeStyles((theme) => {
return {
listItem: {
cursor: "pointer",
height: "auto !important",
},
listItemActive: {
background: "#f4f4f4",
borderRadius: 4,
overflow: "hidden",
},
channelSlug: {
fontFamily: "monospace",
opacity: 0.8,
},
};
});
type ListItem = {
label: string;
id: string;
};
type Props = {
listItems: ListItem[];
activeItemId?: string;
onItemClick(itemId?: string): void;
};
export const ConfigurationsList = ({ listItems, activeItemId, onItemClick }: Props) => {
const styles = useStyles();
return (
<OffsettedList gridTemplate={["1fr"]}>
<OffsettedListBody>
{listItems.map((c) => {
return (
<OffsettedListItem
className={clsx(styles.listItem, {
[styles.listItemActive]: c.id === activeItemId,
})}
key={c.id}
onClick={() => {
onItemClick(c.id);
}}
>
<OffsettedListItemCell>
{c.label}
<IconButton
variant="secondary"
onClick={(event) => {
event.stopPropagation();
event.preventDefault();
}}
>
<DeleteIcon />
</IconButton>
</OffsettedListItemCell>
</OffsettedListItem>
);
})}
<OffsettedListItem
className={clsx(styles.listItem, {
[styles.listItemActive]: activeItemId === undefined,
})}
key="new"
onClick={() => {
onItemClick();
}}
>
<OffsettedListItemCell>Create new</OffsettedListItemCell>
</OffsettedListItem>
</OffsettedListBody>
</OffsettedList>
);
};

View file

@ -1,109 +0,0 @@
import { Card, CardContent, CardHeader, Divider } from "@material-ui/core";
("@material-ui/icons");
import { DeleteIcon, IconButton, List, ListItem, ListItemCell } from "@saleor/macaw-ui";
import clsx from "clsx";
import React from "react";
import { makeStyles } from "@saleor/macaw-ui";
import { Skeleton } from "@material-ui/lab";
export const useStyles = makeStyles((theme) => ({
menu: {
height: "fit-content",
},
clickable: {
cursor: "pointer",
},
selected: {
"&&&&::before": {
position: "absolute",
left: 0,
width: "4px",
height: "100%",
backgroundColor: theme.palette.saleor.active[1],
},
},
spaceBetween: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
},
tableRow: {
minHeight: "48px",
"&::after": {
display: "none",
},
},
greyText: {
color: theme.palette.text.hint,
},
link: {
all: "inherit",
display: "contents",
},
}));
interface SideMenuProps {
title: string;
noItemsText?: string;
items: { id: string; label: string }[];
selectedItemId?: string;
headerToolbar?: React.ReactNode;
onDelete?: (itemId: string) => void;
onClick: (itemId: string) => void;
}
export const SideMenu: React.FC<SideMenuProps> = ({
title,
items,
headerToolbar,
selectedItemId,
noItemsText,
onDelete,
onClick,
}) => {
const classes = useStyles();
const isNoItems = !items || !items.length;
return (
<Card className={classes.menu}>
<CardHeader title={title} action={headerToolbar} />
{isNoItems ? (
!!noItemsText && <CardContent className={classes.greyText}>{noItemsText}</CardContent>
) : (
<List gridTemplate={["1fr"]}>
{items.map((item) => (
<React.Fragment key={item.id}>
<Divider />
<ListItem
className={clsx(classes.clickable, classes.tableRow, {
[classes.selected]: item.id === selectedItemId,
})}
onClick={() => onClick(item.id)}
>
<ListItemCell>
<div className={classes.spaceBetween}>
{item.label}
{!!onDelete && (
<IconButton
variant="secondary"
onClick={(event) => {
event.stopPropagation();
event.preventDefault();
onDelete(item.id);
}}
>
<DeleteIcon />
</IconButton>
)}
</div>
</ListItemCell>
</ListItem>
</React.Fragment>
)) ?? <Skeleton />}
<Divider />
</List>
)}
</Card>
);
};

View file

@ -1,7 +1,6 @@
import { AuthData } from "@saleor/app-sdk/APL"; import { AuthData } from "@saleor/app-sdk/APL";
import { Client } from "urql"; import { Client } from "urql";
import { createLogger } from "@saleor/apps-shared"; import { createLogger } from "@saleor/apps-shared";
import { AppConfigurationService } from "../app-configuration/get-app-configuration.service";
import { MjmlConfigurationService } from "../mjml/configuration/get-mjml-configuration.service"; import { MjmlConfigurationService } from "../mjml/configuration/get-mjml-configuration.service";
import { sendMjml } from "../mjml/send-mjml"; import { sendMjml } from "../mjml/send-mjml";
import { SendgridConfigurationService } from "../sendgrid/configuration/get-sendgrid-configuration.service"; import { SendgridConfigurationService } from "../sendgrid/configuration/get-sendgrid-configuration.service";
@ -31,75 +30,53 @@ export const sendEventMessages = async ({
logger.debug("Function called"); logger.debug("Function called");
const appConfigurationService = new AppConfigurationService({ const mjmlConfigurationService = new MjmlConfigurationService({
apiClient: client, apiClient: client,
saleorApiUrl: authData.saleorApiUrl, saleorApiUrl: authData.saleorApiUrl,
}); });
const channelAppConfiguration = await appConfigurationService.getChannelConfiguration(channel); const availableMjmlConfigurations = await mjmlConfigurationService.getConfigurations({
active: true,
availableInChannel: channel,
});
if (!channelAppConfiguration) { for (const mjmlConfiguration of availableMjmlConfigurations) {
logger.warn("App has no configuration for this channel"); const mjmlStatus = await sendMjml({
return; event,
} payload,
logger.debug("Channel has assigned app configuration"); recipientEmail,
mjmlConfiguration,
if (!channelAppConfiguration.active) {
logger.warn("App configuration is not active for this channel");
return;
}
if (channelAppConfiguration.mjmlConfigurationId) {
logger.debug("Channel has assigned MJML configuration");
const mjmlConfigurationService = new MjmlConfigurationService({
apiClient: client,
saleorApiUrl: authData.saleorApiUrl,
}); });
const mjmlConfiguration = await mjmlConfigurationService.getConfiguration({ if (mjmlStatus?.errors.length) {
id: channelAppConfiguration.mjmlConfigurationId, logger.error("MJML errors");
}); logger.error(mjmlStatus?.errors);
if (mjmlConfiguration) {
const mjmlStatus = await sendMjml({
event,
payload,
recipientEmail,
mjmlConfiguration,
});
if (mjmlStatus?.errors.length) {
logger.error("MJML errors");
logger.error(mjmlStatus?.errors);
}
} }
} }
if (channelAppConfiguration.sendgridConfigurationId) { logger.debug("Channel has assigned Sendgrid configuration");
logger.debug("Channel has assigned Sendgrid configuration");
const sendgridConfigurationService = new SendgridConfigurationService({ const sendgridConfigurationService = new SendgridConfigurationService({
apiClient: client, apiClient: client,
saleorApiUrl: authData.saleorApiUrl, saleorApiUrl: authData.saleorApiUrl,
});
const availableSendgridConfigurations = await sendgridConfigurationService.getConfigurations({
active: true,
availableInChannel: channel,
});
for (const sendgridConfiguration of availableSendgridConfigurations) {
const sendgridStatus = await sendSendgrid({
event,
payload,
recipientEmail,
sendgridConfiguration,
}); });
const sendgridConfiguration = await sendgridConfigurationService.getConfiguration({ if (sendgridStatus?.errors.length) {
id: channelAppConfiguration.sendgridConfigurationId, logger.error("Sendgrid errors");
}); logger.error(sendgridStatus?.errors);
if (sendgridConfiguration) {
const sendgridStatus = await sendSendgrid({
event,
payload,
recipientEmail,
sendgridConfiguration,
});
if (sendgridStatus?.errors.length) {
logger.error("Sendgrid errors");
logger.error(sendgridStatus?.errors);
}
} }
} }
}; };

View file

@ -2,6 +2,7 @@ import { messageEventTypes } from "../../event-handlers/message-event-types";
import { MjmlConfig as MjmlConfigurationRoot, MjmlConfiguration } from "./mjml-config"; import { MjmlConfig as MjmlConfigurationRoot, MjmlConfiguration } from "./mjml-config";
import { defaultMjmlTemplates, defaultMjmlSubjectTemplates } from "../default-templates"; import { defaultMjmlTemplates, defaultMjmlSubjectTemplates } from "../default-templates";
import { generateRandomId } from "../../../lib/generate-random-id"; import { generateRandomId } from "../../../lib/generate-random-id";
import { isAvailableInChannel } from "../../../lib/is-available-in-channel";
export const getDefaultEventsConfiguration = (): MjmlConfiguration["events"] => export const getDefaultEventsConfiguration = (): MjmlConfiguration["events"] =>
messageEventTypes.map((eventType) => ({ messageEventTypes.map((eventType) => ({
@ -24,6 +25,10 @@ export const getDefaultEmptyConfiguration = (): MjmlConfiguration => {
smtpPassword: "", smtpPassword: "",
encryption: "NONE", encryption: "NONE",
events: getDefaultEventsConfiguration(), events: getDefaultEventsConfiguration(),
channels: {
excludedFrom: [],
restrictedTo: [],
},
}; };
return defaultConfig; return defaultConfig;
@ -45,6 +50,7 @@ const getConfiguration =
export interface FilterConfigurationsArgs { export interface FilterConfigurationsArgs {
ids?: string[]; ids?: string[];
availableInChannel?: string;
active?: boolean; active?: boolean;
} }
@ -57,14 +63,28 @@ const getConfigurations =
let filtered = mjmlConfigRoot.configurations; let filtered = mjmlConfigRoot.configurations;
if (filter?.ids?.length) { if (!filter) {
return filtered;
}
if (filter.ids?.length) {
filtered = filtered.filter((c) => filter?.ids?.includes(c.id)); filtered = filtered.filter((c) => filter?.ids?.includes(c.id));
} }
if (filter?.active !== undefined) { if (filter.active !== undefined) {
filtered = filtered.filter((c) => c.active === filter.active); filtered = filtered.filter((c) => c.active === filter.active);
} }
if (filter.availableInChannel?.length) {
filtered = filtered.filter((c) =>
isAvailableInChannel({
channel: filter.availableInChannel!,
restrictedToChannels: c.channels.restrictedTo,
excludedChannels: c.channels.excludedFrom,
})
);
}
return filtered; return filtered;
}; };
@ -79,6 +99,7 @@ const createConfiguration =
id: generateRandomId(), id: generateRandomId(),
events: getDefaultEventsConfiguration(), events: getDefaultEventsConfiguration(),
}; };
mjmlConfigNormalized.configurations.push(newConfiguration); mjmlConfigNormalized.configurations.push(newConfiguration);
return mjmlConfigNormalized; return mjmlConfigNormalized;
}; };

View file

@ -19,6 +19,10 @@ export const mjmlConfigurationBaseObjectSchema = z.object({
smtpUser: z.string(), smtpUser: z.string(),
smtpPassword: z.string(), smtpPassword: z.string(),
encryption: z.enum(smtpEncryptionTypes), encryption: z.enum(smtpEncryptionTypes),
channels: z.object({
excludedFrom: z.array(z.string()),
restrictedTo: z.array(z.string()),
}),
}); });
export const mjmlCreateConfigurationSchema = mjmlConfigurationBaseObjectSchema; export const mjmlCreateConfigurationSchema = mjmlConfigurationBaseObjectSchema;

View file

@ -15,6 +15,10 @@ export interface MjmlConfiguration {
id: string; id: string;
active: boolean; active: boolean;
configurationName: string; configurationName: string;
channels: {
excludedFrom: string[];
restrictedTo: string[];
};
senderName: string; senderName: string;
senderEmail: string; senderEmail: string;
smtpHost: string; smtpHost: string;

View file

@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { IconButton, makeStyles } from "@saleor/macaw-ui"; import { makeStyles } from "@saleor/macaw-ui";
import { AppColumnsLayout } from "../../../ui/app-columns-layout"; import { AppColumnsLayout } from "../../../ui/app-columns-layout";
import { trpcClient } from "../../../trpc/trpc-client"; import { trpcClient } from "../../../trpc/trpc-client";
import { MjmlConfigurationForm } from "./mjml-configuration-form"; import { MjmlConfigurationForm } from "./mjml-configuration-form";
@ -7,11 +7,8 @@ import { getDefaultEmptyConfiguration } from "../mjml-config-container";
import { NextRouter, useRouter } from "next/router"; import { NextRouter, useRouter } from "next/router";
import { mjmlUrls } from "../../urls"; import { mjmlUrls } from "../../urls";
import { MjmlTemplatesCard } from "./mjml-templates-card"; import { MjmlTemplatesCard } from "./mjml-templates-card";
import { SideMenu } from "../../../app-configuration/ui/side-menu";
import { MjmlConfiguration } from "../mjml-config"; import { MjmlConfiguration } from "../mjml-config";
import { LoadingIndicator } from "../../../ui/loading-indicator"; import { LoadingIndicator } from "../../../ui/loading-indicator";
import { Add } from "@material-ui/icons";
import { useQueryClient } from "@tanstack/react-query";
import { MjmlInstructions } from "./mjml-instructions"; import { MjmlInstructions } from "./mjml-instructions";
import { useDashboardNotification } from "@saleor/apps-shared"; import { useDashboardNotification } from "@saleor/apps-shared";
@ -42,6 +39,7 @@ const navigateToFirstConfiguration = (router: NextRouter, configurations?: MjmlC
return; return;
} }
const firstConfigurationId = configurations[0]?.id; const firstConfigurationId = configurations[0]?.id;
if (firstConfigurationId) { if (firstConfigurationId) {
router.replace(mjmlUrls.configuration(firstConfigurationId)); router.replace(mjmlUrls.configuration(firstConfigurationId));
return; return;
@ -50,16 +48,13 @@ const navigateToFirstConfiguration = (router: NextRouter, configurations?: MjmlC
export const MjmlConfigurationTab = ({ configurationId }: MjmlConfigurationTabProps) => { export const MjmlConfigurationTab = ({ configurationId }: MjmlConfigurationTabProps) => {
const styles = useStyles(); const styles = useStyles();
const { notifyError, notifySuccess } = useDashboardNotification();
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient();
const { const {
data: configurations, data: configurations,
refetch: refetchConfigurations, refetch: refetchConfigurations,
isLoading: configurationsIsLoading, isLoading: configurationsIsLoading,
isFetching: configurationsIsFetching, isFetching: configurationsIsFetching,
isRefetching: configurationsIsRefetching,
} = trpcClient.mjmlConfiguration.getConfigurations.useQuery(undefined, { } = trpcClient.mjmlConfiguration.getConfigurations.useQuery(undefined, {
onSuccess(data) { onSuccess(data) {
if (!configurationId) { if (!configurationId) {
@ -69,38 +64,6 @@ export const MjmlConfigurationTab = ({ configurationId }: MjmlConfigurationTabPr
}, },
}); });
const { mutate: deleteConfiguration } =
trpcClient.mjmlConfiguration.deleteConfiguration.useMutation({
onError: (error) => {
notifyError("Could not remove the configuration", error.message);
},
onSuccess: async (_data, variables) => {
await queryClient.cancelQueries({ queryKey: ["mjmlConfiguration", "getConfigurations"] });
// remove value from the cache after the success
queryClient.setQueryData<Array<MjmlConfiguration>>(
["mjmlConfiguration", "getConfigurations"],
(old) => {
if (old) {
const index = old.findIndex((c) => c.id === variables.id);
if (index !== -1) {
delete old[index];
return [...old];
}
}
}
);
// if we just deleted the configuration that was selected
// we have to update the URL
if (variables.id === configurationId) {
router.replace(mjmlUrls.configuration());
}
refetchConfigurations();
notifySuccess("Success", "Removed successfully");
},
});
if (configurationsIsLoading || configurationsIsFetching) { if (configurationsIsLoading || configurationsIsFetching) {
return <LoadingIndicator />; return <LoadingIndicator />;
} }
@ -113,25 +76,6 @@ export const MjmlConfigurationTab = ({ configurationId }: MjmlConfigurationTabPr
return ( return (
<AppColumnsLayout> <AppColumnsLayout>
<SideMenu
title="Configurations"
selectedItemId={configurationId}
headerToolbar={
<IconButton
variant="secondary"
onClick={() => {
router.replace(mjmlUrls.configuration());
}}
>
<Add />
</IconButton>
}
onClick={(id) => router.replace(mjmlUrls.configuration(id))}
onDelete={(id) => {
deleteConfiguration({ id });
}}
items={configurations?.map((c) => ({ label: c.configurationName, id: c.id })) || []}
/>
<div className={styles.configurationColumn}> <div className={styles.configurationColumn}>
{configurationsIsLoading || configurationsIsFetching ? ( {configurationsIsLoading || configurationsIsFetching ? (
<LoadingIndicator /> <LoadingIndicator />

View file

@ -1,4 +1,5 @@
import { generateRandomId } from "../../../lib/generate-random-id"; import { generateRandomId } from "../../../lib/generate-random-id";
import { isAvailableInChannel } from "../../../lib/is-available-in-channel";
import { messageEventTypes } from "../../event-handlers/message-event-types"; import { messageEventTypes } from "../../event-handlers/message-event-types";
import { import {
SendgridConfig as SendgridConfigurationRoot, SendgridConfig as SendgridConfigurationRoot,
@ -22,6 +23,10 @@ export const getDefaultEmptyConfiguration = (): SendgridConfiguration => {
apiKey: "", apiKey: "",
sandboxMode: false, sandboxMode: false,
events: getDefaultEventsConfiguration(), events: getDefaultEventsConfiguration(),
channels: {
excludedFrom: [],
restrictedTo: [],
},
}; };
return defaultConfig; return defaultConfig;
@ -44,6 +49,7 @@ const getConfiguration =
export interface FilterConfigurationsArgs { export interface FilterConfigurationsArgs {
ids?: string[]; ids?: string[];
active?: boolean; active?: boolean;
availableInChannel?: string;
} }
const getConfigurations = const getConfigurations =
@ -55,14 +61,28 @@ const getConfigurations =
let filtered = sendgridConfigRoot.configurations; let filtered = sendgridConfigRoot.configurations;
if (filter?.ids?.length) { if (!filter) {
return filtered;
}
if (filter.ids?.length) {
filtered = filtered.filter((c) => filter?.ids?.includes(c.id)); filtered = filtered.filter((c) => filter?.ids?.includes(c.id));
} }
if (filter?.active !== undefined) { if (filter.active !== undefined) {
filtered = filtered.filter((c) => c.active === filter.active); filtered = filtered.filter((c) => c.active === filter.active);
} }
if (filter.availableInChannel?.length) {
filtered = filtered.filter((c) =>
isAvailableInChannel({
channel: filter.availableInChannel!,
restrictedToChannels: c.channels.restrictedTo,
excludedChannels: c.channels.excludedFrom,
})
);
}
return filtered; return filtered;
}; };
@ -77,6 +97,7 @@ const createConfiguration =
id: generateRandomId(), id: generateRandomId(),
events: getDefaultEventsConfiguration(), events: getDefaultEventsConfiguration(),
}; };
sendgridConfigNormalized.configurations.push(newConfiguration); sendgridConfigNormalized.configurations.push(newConfiguration);
return sendgridConfigNormalized; return sendgridConfigNormalized;
}; };

View file

@ -14,6 +14,10 @@ export const sendgridConfigurationBaseObjectSchema = z.object({
apiKey: z.string().min(1), apiKey: z.string().min(1),
senderName: z.string().min(1).optional(), senderName: z.string().min(1).optional(),
senderEmail: z.string().email().min(5).optional(), senderEmail: z.string().email().min(5).optional(),
channels: z.object({
excludedFrom: z.array(z.string()),
restrictedTo: z.array(z.string()),
}),
}); });
export const sendgridCreateConfigurationSchema = sendgridConfigurationBaseObjectSchema.omit({ export const sendgridCreateConfigurationSchema = sendgridConfigurationBaseObjectSchema.omit({

View file

@ -15,6 +15,10 @@ export interface SendgridConfiguration {
senderEmail?: string; senderEmail?: string;
apiKey: string; apiKey: string;
events: SendgridEventConfiguration[]; events: SendgridEventConfiguration[];
channels: {
excludedFrom: string[];
restrictedTo: string[];
};
} }
export type SendgridConfig = { export type SendgridConfig = {

View file

@ -1,19 +1,15 @@
import React from "react"; import React from "react";
import { IconButton, makeStyles } from "@saleor/macaw-ui"; import { makeStyles } from "@saleor/macaw-ui";
import { AppColumnsLayout } from "../../../ui/app-columns-layout"; import { AppColumnsLayout } from "../../../ui/app-columns-layout";
import { trpcClient } from "../../../trpc/trpc-client"; import { trpcClient } from "../../../trpc/trpc-client";
import { SendgridConfigurationForm } from "./sendgrid-configuration-form"; import { SendgridConfigurationForm } from "./sendgrid-configuration-form";
import { getDefaultEmptyConfiguration } from "../sendgrid-config-container"; import { getDefaultEmptyConfiguration } from "../sendgrid-config-container";
import { NextRouter, useRouter } from "next/router"; import { NextRouter, useRouter } from "next/router";
import { SideMenu } from "../../../app-configuration/ui/side-menu";
import { SendgridConfiguration } from "../sendgrid-config"; import { SendgridConfiguration } from "../sendgrid-config";
import { LoadingIndicator } from "../../../ui/loading-indicator"; import { LoadingIndicator } from "../../../ui/loading-indicator";
import { Add } from "@material-ui/icons";
import { useQueryClient } from "@tanstack/react-query";
import { sendgridUrls } from "../../urls"; import { sendgridUrls } from "../../urls";
import { SendgridTemplatesCard } from "./sendgrid-templates-card"; import { SendgridTemplatesCard } from "./sendgrid-templates-card";
import { SendgridInstructions } from "./sendgrid-instructions"; import { SendgridInstructions } from "./sendgrid-instructions";
import { useDashboardNotification } from "@saleor/apps-shared";
const useStyles = makeStyles((theme) => { const useStyles = makeStyles((theme) => {
return { return {
@ -45,6 +41,7 @@ const navigateToFirstConfiguration = (
return; return;
} }
const firstConfigurationId = configurations[0]?.id; const firstConfigurationId = configurations[0]?.id;
if (firstConfigurationId) { if (firstConfigurationId) {
router.replace(sendgridUrls.configuration(firstConfigurationId)); router.replace(sendgridUrls.configuration(firstConfigurationId));
return; return;
@ -54,15 +51,12 @@ const navigateToFirstConfiguration = (
export const SendgridConfigurationTab = ({ configurationId }: SendgridConfigurationTabProps) => { export const SendgridConfigurationTab = ({ configurationId }: SendgridConfigurationTabProps) => {
const styles = useStyles(); const styles = useStyles();
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient();
const { notifySuccess, notifyError } = useDashboardNotification();
const { const {
data: configurations, data: configurations,
refetch: refetchConfigurations, refetch: refetchConfigurations,
isLoading: configurationsIsLoading, isLoading: configurationsIsLoading,
isFetching: configurationsIsFetching, isFetching: configurationsIsFetching,
isRefetching: configurationsIsRefetching,
} = trpcClient.sendgridConfiguration.getConfigurations.useQuery(undefined, { } = trpcClient.sendgridConfiguration.getConfigurations.useQuery(undefined, {
onSuccess(data) { onSuccess(data) {
if (!configurationId) { if (!configurationId) {
@ -72,41 +66,6 @@ export const SendgridConfigurationTab = ({ configurationId }: SendgridConfigurat
}, },
}); });
const { mutate: deleteConfiguration } =
trpcClient.sendgridConfiguration.deleteConfiguration.useMutation({
onError: (error) => {
notifyError("Could not remove the configuration", error.message);
},
onSuccess: async (_data, variables) => {
await queryClient.cancelQueries({
queryKey: ["sendgridConfiguration", "getConfigurations"],
});
// remove value from the cache after the success
queryClient.setQueryData<Array<SendgridConfiguration>>(
["sendgridConfiguration", "getConfigurations"],
(old) => {
if (old) {
const index = old.findIndex((c) => c.id === variables.id);
if (index !== -1) {
delete old[index];
return [...old];
}
}
}
);
// if we just deleted the configuration that was selected
// we have to update the URL
if (variables.id === configurationId) {
router.replace(sendgridUrls.configuration());
}
refetchConfigurations();
notifySuccess("Success", "Removed successfully");
},
});
if (configurationsIsLoading || configurationsIsFetching) { if (configurationsIsLoading || configurationsIsFetching) {
return <LoadingIndicator />; return <LoadingIndicator />;
} }
@ -119,25 +78,6 @@ export const SendgridConfigurationTab = ({ configurationId }: SendgridConfigurat
return ( return (
<AppColumnsLayout> <AppColumnsLayout>
<SideMenu
title="Configurations"
selectedItemId={configurationId}
headerToolbar={
<IconButton
variant="secondary"
onClick={() => {
router.replace(sendgridUrls.configuration());
}}
>
<Add />
</IconButton>
}
onClick={(id) => router.replace(sendgridUrls.configuration(id))}
onDelete={(id) => {
deleteConfiguration({ id });
}}
items={configurations?.map((c) => ({ label: c.configurationName, id: c.id })) || []}
/>
<div className={styles.configurationColumn}> <div className={styles.configurationColumn}>
{configurationsIsLoading || configurationsIsFetching ? ( {configurationsIsLoading || configurationsIsFetching ? (
<LoadingIndicator /> <LoadingIndicator />

View file

@ -1,12 +1,10 @@
import { channelsRouter } from "../channels/channels.router"; import { channelsRouter } from "../channels/channels.router";
import { router } from "./trpc-server"; import { router } from "./trpc-server";
import { appConfigurationRouter } from "../app-configuration/app-configuration.router";
import { mjmlConfigurationRouter } from "../mjml/configuration/mjml-configuration.router"; import { mjmlConfigurationRouter } from "../mjml/configuration/mjml-configuration.router";
import { sendgridConfigurationRouter } from "../sendgrid/configuration/sendgrid-configuration.router"; import { sendgridConfigurationRouter } from "../sendgrid/configuration/sendgrid-configuration.router";
export const appRouter = router({ export const appRouter = router({
channels: channelsRouter, channels: channelsRouter,
appConfiguration: appConfigurationRouter,
mjmlConfiguration: mjmlConfigurationRouter, mjmlConfiguration: mjmlConfigurationRouter,
sendgridConfiguration: sendgridConfigurationRouter, sendgridConfiguration: sendgridConfigurationRouter,
}); });

View file

@ -4,7 +4,7 @@ import { PropsWithChildren } from "react";
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
root: { root: {
display: "grid", display: "grid",
gridTemplateColumns: "280px auto 400px", gridTemplateColumns: "auto 400px",
alignItems: "start", alignItems: "start",
gap: theme.spacing(3), gap: theme.spacing(3),
padding: "20px 0", padding: "20px 0",

View file

@ -3,12 +3,14 @@ import React, { useEffect } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { trpcClient } from "../../../modules/trpc/trpc-client"; import { trpcClient } from "../../../modules/trpc/trpc-client";
import { ConfigurationPageBaseLayout } from "../../../modules/ui/configuration-page-base-layout"; import { ConfigurationPageBaseLayout } from "../../../modules/ui/configuration-page-base-layout";
import { ChannelsConfigurationTab } from "../../../modules/app-configuration/ui/channels-configuration-tab";
const ChannelsConfigurationPage: NextPage = () => { const ChannelsConfigurationPage: NextPage = () => {
const channels = trpcClient.channels.fetch.useQuery(); const channels = trpcClient.channels.fetch.useQuery();
const router = useRouter(); const router = useRouter();
const sendgridConfigurations = trpcClient.sendgridConfiguration.getConfigurations.useQuery();
const mjmlConfigurations = trpcClient.mjmlConfiguration.getConfigurations.useQuery();
useEffect(() => { useEffect(() => {
if (router && channels.isSuccess && channels.data.length === 0) { if (router && channels.isSuccess && channels.data.length === 0) {
router.push("/not-ready"); router.push("/not-ready");
@ -16,7 +18,18 @@ const ChannelsConfigurationPage: NextPage = () => {
}, [channels.data, channels.isSuccess, router]); }, [channels.data, channels.isSuccess, router]);
return ( return (
<ConfigurationPageBaseLayout> <ConfigurationPageBaseLayout>
<ChannelsConfigurationTab /> Sendgrid configurations:
<ul>
{sendgridConfigurations.data?.map((c) => (
<li key={c.id}>{c.configurationName}</li>
))}
</ul>
MJML configurations:
<ul>
{mjmlConfigurations.data?.map((c) => (
<li key={c.id}>{c.configurationName}</li>
))}
</ul>
</ConfigurationPageBaseLayout> </ConfigurationPageBaseLayout>
); );
}; };

View file

@ -1,4 +1,4 @@
query FetchProductDataForFeed($first:Int!, $after: String, $channel: String!){ query FetchProductDataForFeed($first:Int!, $after: String, $channel: String!, $language: String!){
productVariants(first:$first, after: $after, channel: $channel){ productVariants(first:$first, after: $after, channel: $channel){
pageInfo{ pageInfo{
hasNextPage hasNextPage