Improve CMS app UX & UI (#349)
* Install apps-shared, render null if app is loading in the iframe Redesign the app Add notification toasts and imrpve instructions Change channels list to select Replace lists with select * fix build * Fix build
This commit is contained in:
parent
3acc1553a8
commit
1da51639fa
35 changed files with 436 additions and 292 deletions
5
.changeset/rare-crews-guess.md
Normal file
5
.changeset/rare-crews-guess.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"saleor-app-cms": minor
|
||||
---
|
||||
|
||||
Redesigned the app to better match new Dashboard
|
|
@ -14,6 +14,7 @@ module.exports = {
|
|||
disableServerWebpackPlugin: !isSentryPropertiesInEnvironment,
|
||||
disableClientWebpackPlugin: !isSentryPropertiesInEnvironment,
|
||||
},
|
||||
transpilePackages: ["@saleor/apps-shared"],
|
||||
};
|
||||
|
||||
module.exports = withSentryConfig(module.exports, { silent: true }, { hideSourcemaps: true });
|
||||
|
|
|
@ -38,7 +38,8 @@
|
|||
"usehooks-ts": "^2.9.1",
|
||||
"uuid": "^9.0.0",
|
||||
"vite": "^4.1.4",
|
||||
"zod": "^3.19.1"
|
||||
"zod": "^3.19.1",
|
||||
"@saleor/apps-shared": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@graphql-codegen/cli": "2.13.3",
|
||||
|
|
|
@ -1,14 +1,13 @@
|
|||
import { NextWebhookApiHandler } from "@saleor/app-sdk/handlers/next";
|
||||
import { createClient } from "../../graphql";
|
||||
import { createSettingsManager } from "../../metadata";
|
||||
import { getOperationType } from "./operations";
|
||||
import {
|
||||
getChannelsSettings,
|
||||
getProviderInstancesSettings,
|
||||
getProductVariantProviderInstancesToAlter,
|
||||
getProviderInstancesSettings,
|
||||
} from "./settings";
|
||||
import { providersSchemaSet } from "../config";
|
||||
import { cmsProviders, CMSProvider } from "../providers";
|
||||
import { CMSProvider, cmsProviders } from "../providers";
|
||||
import { CmsClientOperations } from "../types";
|
||||
import { logger as pinoLogger } from "../../logger";
|
||||
import { getCmsIdFromSaleorItemKey } from "./metadata";
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
import { AuthData } from "@saleor/app-sdk/APL";
|
||||
import { NextWebhookApiHandler } from "@saleor/app-sdk/handlers/next";
|
||||
import {
|
||||
DeleteMetadataDocument,
|
||||
ProductVariantUpdatedWebhookPayloadFragment,
|
||||
UpdateMetadataDocument,
|
||||
WebhookProductVariantFragment,
|
||||
} from "../../../../generated/graphql";
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { EncryptedMetadataManager } from "@saleor/app-sdk/settings-manager";
|
||||
import { CMSSchemaChannels, CMSSchemaProviderInstances } from "../config";
|
||||
import { createCmsKeyForSaleorItem, getCmsIdFromSaleorItemKey } from "./metadata";
|
||||
|
||||
export const getChannelsSettings = async (settingsManager: EncryptedMetadataManager) => {
|
||||
const channelsSettings = await settingsManager.get("channels");
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { MetadataEntry, EncryptedMetadataManager } from "@saleor/app-sdk/settings-manager";
|
||||
import { EncryptedMetadataManager, MetadataEntry } from "@saleor/app-sdk/settings-manager";
|
||||
import { Client } from "urql";
|
||||
|
||||
import {
|
||||
|
|
|
@ -1,18 +1,17 @@
|
|||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Checkbox, FormControl, FormControlLabel, Switch, Typography } from "@material-ui/core";
|
||||
import { Checkbox, FormControl, Typography } from "@material-ui/core";
|
||||
import {
|
||||
makeStyles,
|
||||
Button,
|
||||
List,
|
||||
ListBody,
|
||||
ListFooter,
|
||||
ListHeader,
|
||||
ListItem,
|
||||
ListItemCell,
|
||||
ListFooter,
|
||||
Button,
|
||||
makeStyles,
|
||||
} from "@saleor/macaw-ui";
|
||||
import React from "react";
|
||||
import { Controller, useController, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
channelSchema,
|
||||
ChannelSchema,
|
||||
|
@ -39,6 +38,10 @@ const useStyles = makeStyles((theme) => {
|
|||
justifyContent: "flex-end",
|
||||
padding: theme.spacing(2, 4),
|
||||
},
|
||||
form: {
|
||||
border: `1px solid hsla(212, 44%, 13%, 0.08)`,
|
||||
borderRadius: 8,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
@ -87,7 +90,7 @@ export const ChannelConfigurationForm = ({
|
|||
const errors = formState.errors;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className={styles.form}>
|
||||
{!!Object.entries(errors).length && (
|
||||
<Typography variant="body1" color="error">
|
||||
Error validating form
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
import { AppPaper } from "../../ui/app-paper";
|
||||
import { FormControlLabel, Grid, Paper, Radio, RadioGroup, Typography } from "@material-ui/core";
|
||||
import { Grid, Paper, Typography } from "@material-ui/core";
|
||||
import { Skeleton } from "@material-ui/lab";
|
||||
import { ChannelConfigurationForm } from "./channel-configuration-form";
|
||||
import {
|
||||
MergedChannelSchema,
|
||||
ProvidersSchema,
|
||||
SingleChannelSchema,
|
||||
SingleProviderSchema,
|
||||
} from "../../../lib/cms/config";
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { makeStyles } from "@saleor/macaw-ui";
|
||||
import {
|
||||
makeStyles,
|
||||
OffsettedList,
|
||||
OffsettedListBody,
|
||||
OffsettedListHeader,
|
||||
|
@ -7,9 +7,7 @@ import {
|
|||
OffsettedListItemCell,
|
||||
} from "@saleor/macaw-ui";
|
||||
import clsx from "clsx";
|
||||
import { ChannelFragment } from "../../../../generated/graphql";
|
||||
import { MergedChannelSchema, SingleChannelSchema } from "../../../lib/cms/config";
|
||||
import { ProviderIcon } from "../../provider-instances/ui/provider-icon";
|
||||
import { MergedChannelSchema } from "../../../lib/cms/config";
|
||||
|
||||
const useStyles = makeStyles((theme) => {
|
||||
return {
|
||||
|
|
|
@ -1,25 +1,14 @@
|
|||
import { Grid } from "@material-ui/core";
|
||||
import { Skeleton } from "@material-ui/lab";
|
||||
import { ChannelFragment } from "../../../../generated/graphql";
|
||||
import { MergedChannelSchema, SingleChannelSchema } from "../../../lib/cms";
|
||||
import { MergedChannelSchema } from "../../../lib/cms";
|
||||
import { AppPaper } from "../../ui/app-paper";
|
||||
import { ChannelsListItems } from "./channels-list-items";
|
||||
|
||||
import { ChannelsErrors, ChannelsLoading } from "./types";
|
||||
import { ChannelsSelect } from "./channels-select";
|
||||
|
||||
const ChannelsListSkeleton = () => {
|
||||
return (
|
||||
<AppPaper>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<Skeleton variant="rect" width={"45%"} height={10} />
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Skeleton variant="rect" width={"100%"} height={30} />
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Skeleton variant="rect" width={"100%"} height={30} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Skeleton variant="rect" width={"100%"} height={30} />
|
||||
</AppPaper>
|
||||
);
|
||||
};
|
||||
|
@ -48,7 +37,7 @@ export const ChannelsList = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<ChannelsListItems
|
||||
<ChannelsSelect
|
||||
channels={channels}
|
||||
activeChannel={activeChannel}
|
||||
setActiveChannel={setActiveChannel}
|
||||
|
|
41
apps/cms/src/modules/channels/ui/channels-select.tsx
Normal file
41
apps/cms/src/modules/channels/ui/channels-select.tsx
Normal file
|
@ -0,0 +1,41 @@
|
|||
import { MergedChannelSchema } from "../../../lib/cms/config";
|
||||
import { FormControl, InputLabel, MenuItem, Select, SelectProps } from "@material-ui/core";
|
||||
|
||||
interface ChannelsListItemsProps extends SelectProps {
|
||||
channels: MergedChannelSchema[];
|
||||
activeChannel?: MergedChannelSchema | null;
|
||||
setActiveChannel: (channel: MergedChannelSchema | null) => void;
|
||||
}
|
||||
|
||||
export const ChannelsSelect = ({
|
||||
channels,
|
||||
activeChannel,
|
||||
setActiveChannel,
|
||||
...props
|
||||
}: ChannelsListItemsProps) => {
|
||||
console.log(activeChannel);
|
||||
|
||||
return (
|
||||
<FormControl>
|
||||
<InputLabel id="channel-select">Select channel to configure</InputLabel>
|
||||
|
||||
<Select
|
||||
labelId="channel-select"
|
||||
{...props}
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
value={activeChannel?.channel.id}
|
||||
onChange={(e, value) => {
|
||||
console.log(e.target.value);
|
||||
setActiveChannel(channels.find((c) => c.channel.id === e.target.value)!);
|
||||
}}
|
||||
>
|
||||
{channels.map((c) => (
|
||||
<MenuItem key={c.channel.id} value={c.channel.id}>
|
||||
{c.channel.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
};
|
|
@ -1,12 +1,22 @@
|
|||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { MergedChannelSchema } from "../../../lib/cms/config";
|
||||
import { useProviderInstances } from "../../provider-instances/ui/hooks/useProviderInstances";
|
||||
import { Instructions } from "../../ui/instructions";
|
||||
import { ChannelConfiguration } from "./channel-configuration";
|
||||
import { ChannelsList } from "./channels-list";
|
||||
import { useChannels } from "./hooks/useChannels";
|
||||
import { AppTabs } from "../../ui/app-tabs";
|
||||
import { makeStyles } from "@saleor/macaw-ui";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
wrapper: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
},
|
||||
});
|
||||
|
||||
export const Channels = () => {
|
||||
const styles = useStyles();
|
||||
const { channels, saveChannel, loading, errors } = useChannels();
|
||||
const { providerInstances } = useProviderInstances();
|
||||
|
||||
|
@ -20,23 +30,32 @@ export const Channels = () => {
|
|||
|
||||
const activeChannel = channels.find((channel) => channel.channelSlug === activeChannelSlug);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeChannelSlug && channels.length > 0) {
|
||||
setActiveChannelSlug(channels[0].channelSlug);
|
||||
}
|
||||
}, [channels]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChannelsList
|
||||
channels={channels}
|
||||
activeChannel={activeChannel}
|
||||
setActiveChannel={handleSetActiveChannel}
|
||||
loading={loading}
|
||||
errors={errors}
|
||||
/>
|
||||
<ChannelConfiguration
|
||||
activeChannel={activeChannel}
|
||||
providerInstances={providerInstances}
|
||||
saveChannel={saveChannel}
|
||||
loading={loading}
|
||||
errors={errors}
|
||||
/>
|
||||
<Instructions />
|
||||
<AppTabs activeTab="channels" />
|
||||
|
||||
<div className={styles.wrapper}>
|
||||
<ChannelsList
|
||||
channels={channels}
|
||||
activeChannel={activeChannel}
|
||||
setActiveChannel={handleSetActiveChannel}
|
||||
loading={loading}
|
||||
errors={errors}
|
||||
/>
|
||||
<ChannelConfiguration
|
||||
activeChannel={activeChannel}
|
||||
providerInstances={providerInstances}
|
||||
saveChannel={saveChannel}
|
||||
loading={loading}
|
||||
errors={errors}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -3,8 +3,10 @@ import { MergedChannelSchema, SingleChannelSchema } from "../../../../lib/cms/co
|
|||
import { ChannelsErrors, ChannelsLoading } from "../types";
|
||||
import { useChannelsQuery } from "../../../../../generated/graphql";
|
||||
import { useIsMounted } from "usehooks-ts";
|
||||
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
|
||||
export const useChannels = () => {
|
||||
const { appBridge } = useAppBridge();
|
||||
const isMounted = useIsMounted();
|
||||
const [channelsQueryData, channelsQueryOpts] = useChannelsQuery({
|
||||
pause: !isMounted,
|
||||
|
@ -20,7 +22,15 @@ export const useChannels = () => {
|
|||
const saveChannel = (channelToSave: SingleChannelSchema) => {
|
||||
console.log("saveChannel", channelToSave);
|
||||
|
||||
saveChannelFetch(channelToSave);
|
||||
saveChannelFetch(channelToSave).then(() => {
|
||||
appBridge?.dispatch(
|
||||
actions.Notification({
|
||||
title: "Success",
|
||||
status: "success",
|
||||
text: "Configuration saved",
|
||||
})
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const loading: ChannelsLoading = {
|
||||
|
|
|
@ -1,10 +1,6 @@
|
|||
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import React from "react";
|
||||
import {
|
||||
CMSSchemaChannels,
|
||||
cmsSchemaChannels,
|
||||
SingleChannelSchema,
|
||||
} from "../../../../lib/cms/config";
|
||||
import { CMSSchemaChannels, SingleChannelSchema } from "../../../../lib/cms/config";
|
||||
import { SALEOR_API_URL_HEADER, SALEOR_AUTHORIZATION_BEARER_HEADER } from "@saleor/app-sdk/const";
|
||||
import { ChannelsApiResponse } from "../../../../pages/api/channels";
|
||||
|
||||
|
|
|
@ -13,8 +13,6 @@ export const useProviderInstances = () => {
|
|||
} = useProviderInstancesFetch();
|
||||
|
||||
const saveProviderInstance = async (providerInstanceToSave: SingleProviderSchema) => {
|
||||
console.log("saveProviderInstance", providerInstanceToSave);
|
||||
|
||||
return await saveProviderInstanceFetch(providerInstanceToSave);
|
||||
};
|
||||
|
||||
|
|
|
@ -55,6 +55,8 @@ export const useProviderInstancesFetch = () => {
|
|||
};
|
||||
|
||||
const saveProviderInstance = async (instance: SingleProviderSchema) => {
|
||||
console.log(instance);
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
const response = await fetch("/api/provider-instances", {
|
||||
|
|
|
@ -2,16 +2,13 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
|||
import { Grid, TextField, Typography } from "@material-ui/core";
|
||||
import { Button, makeStyles } from "@saleor/macaw-ui";
|
||||
import React from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { Path, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
providersConfig,
|
||||
CMSProviderSchema,
|
||||
providersSchemaSet,
|
||||
providersConfig,
|
||||
ProvidersSchema,
|
||||
providersSchemaSet,
|
||||
SingleProviderSchema,
|
||||
ProviderInstanceSchema,
|
||||
} from "../../../lib/cms/config";
|
||||
import { Provider } from "../../providers/config";
|
||||
import { AppMarkdownText } from "../../ui/app-markdown-text";
|
||||
|
@ -68,7 +65,6 @@ export const ProviderInstanceConfigurationForm = <TProvider extends CMSProviderS
|
|||
});
|
||||
|
||||
if (providerInstance && providerInstance.providerName === provider.name) {
|
||||
console.log(providerInstance);
|
||||
reset(providerInstance as ProvidersSchema[TProvider]);
|
||||
}
|
||||
}, [provider, providerInstance]);
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { FormControlLabel, Grid, Radio, RadioGroup, Typography } from "@material-ui/core";
|
||||
import { makeStyles } from "@saleor/macaw-ui";
|
||||
import { Button, makeStyles } from "@saleor/macaw-ui";
|
||||
import Image from "next/image";
|
||||
import React from "react";
|
||||
import { CMSProviderSchema, providersConfig, SingleProviderSchema } from "../../../lib/cms/config";
|
||||
|
@ -39,6 +39,18 @@ const useStyles = makeStyles((theme) => ({
|
|||
textAlign: "center",
|
||||
margin: theme.spacing(1, 0, 3, 0),
|
||||
},
|
||||
newProviderButton: {
|
||||
margin: "30px auto",
|
||||
},
|
||||
newProviderContainer: {
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
},
|
||||
box: {
|
||||
border: `1px solid hsla(212, 44%, 13%, 0.08)`,
|
||||
borderRadius: 8,
|
||||
padding: 20,
|
||||
},
|
||||
}));
|
||||
|
||||
const ProviderInstanceConfigurationSkeleton = () => {
|
||||
|
@ -82,6 +94,7 @@ interface ProviderInstanceConfigurationProps {
|
|||
deleteProviderInstance: (providerInstance: SingleProviderSchema) => any;
|
||||
loading: ProvidersLoading;
|
||||
errors: ProvidersErrors;
|
||||
onNewProviderRequest(): void;
|
||||
}
|
||||
|
||||
export const ProviderInstanceConfiguration = ({
|
||||
|
@ -90,6 +103,7 @@ export const ProviderInstanceConfiguration = ({
|
|||
saveProviderInstance,
|
||||
deleteProviderInstance,
|
||||
loading,
|
||||
onNewProviderRequest,
|
||||
errors,
|
||||
}: ProviderInstanceConfigurationProps) => {
|
||||
const [selectedProvider, setSelectedProvider] = React.useState<Provider | undefined>(
|
||||
|
@ -120,70 +134,86 @@ export const ProviderInstanceConfiguration = ({
|
|||
<Typography variant="body1" className={styles.textCenter}>
|
||||
Please select a provider configuration or add new one.
|
||||
</Typography>
|
||||
<div className={styles.newProviderContainer}>
|
||||
<Button
|
||||
onClick={onNewProviderRequest}
|
||||
variant="primary"
|
||||
className={styles.newProviderButton}
|
||||
>
|
||||
Create a Provider config
|
||||
</Button>
|
||||
</div>
|
||||
</AppPaper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppPaper>
|
||||
{errors.fetching && (
|
||||
<Typography variant="body1" color="error">
|
||||
Error fetching available providers
|
||||
</Typography>
|
||||
)}
|
||||
{errors.saving && (
|
||||
<Typography variant="body1" color="error">
|
||||
Error saving provider configuration
|
||||
</Typography>
|
||||
)}
|
||||
{!!newProviderInstance && (
|
||||
<Typography variant="h3" className={styles.textHeader}>
|
||||
Add new configuration
|
||||
</Typography>
|
||||
)}
|
||||
<RadioGroup value={selectedProvider?.name ?? ""} onChange={handleProviderChange}>
|
||||
<Grid container justifyContent="center">
|
||||
{Object.entries(providersConfig).map(([name, config]) => (
|
||||
<Grid className={styles.gridItem} item xs={4} key={name}>
|
||||
<FormControlLabel
|
||||
className={
|
||||
selectedProvider?.name === name
|
||||
? `${styles.radioLabelActive} ${styles.radioLabel}`
|
||||
: styles.radioLabel
|
||||
}
|
||||
control={<Radio style={{ display: "none" }} name="provider" value={name} />}
|
||||
label={
|
||||
<div className={styles.iconWithLabel}>
|
||||
<Image src={config.icon} alt={`${config.label} icon`} height={32} width={32} />
|
||||
<Typography variant="body1">{config.label}</Typography>
|
||||
</div>
|
||||
}
|
||||
labelPlacement="top"
|
||||
aria-label={config.label}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</RadioGroup>
|
||||
{selectedProvider ? (
|
||||
<>
|
||||
<br />
|
||||
<ProviderInstanceConfigurationForm
|
||||
provider={selectedProvider}
|
||||
providerInstance={activeProviderInstance}
|
||||
loading={loading.saving}
|
||||
onSubmit={saveProviderInstance}
|
||||
onDelete={deleteProviderInstance}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<br />
|
||||
<Typography variant="body1" className={styles.textCenter}>
|
||||
Please select a provider.
|
||||
<AppPaper className={styles.box}>
|
||||
<div>
|
||||
{errors.fetching && (
|
||||
<Typography variant="body1" color="error">
|
||||
Error fetching available providers
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
{errors.saving && (
|
||||
<Typography variant="body1" color="error">
|
||||
Error saving provider configuration
|
||||
</Typography>
|
||||
)}
|
||||
{!!newProviderInstance && (
|
||||
<Typography variant="h3" className={styles.textHeader}>
|
||||
Add new configuration
|
||||
</Typography>
|
||||
)}
|
||||
<RadioGroup value={selectedProvider?.name ?? ""} onChange={handleProviderChange}>
|
||||
<Grid container justifyContent="center">
|
||||
{Object.entries(providersConfig).map(([name, config]) => (
|
||||
<Grid className={styles.gridItem} item xs={4} key={name}>
|
||||
<FormControlLabel
|
||||
className={
|
||||
selectedProvider?.name === name
|
||||
? `${styles.radioLabelActive} ${styles.radioLabel}`
|
||||
: styles.radioLabel
|
||||
}
|
||||
control={<Radio style={{ display: "none" }} name="provider" value={name} />}
|
||||
label={
|
||||
<div className={styles.iconWithLabel}>
|
||||
<Image
|
||||
src={config.icon}
|
||||
alt={`${config.label} icon`}
|
||||
height={32}
|
||||
width={32}
|
||||
/>
|
||||
<Typography variant="body1">{config.label}</Typography>
|
||||
</div>
|
||||
}
|
||||
labelPlacement="top"
|
||||
aria-label={config.label}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</RadioGroup>
|
||||
{selectedProvider ? (
|
||||
<>
|
||||
<br />
|
||||
<ProviderInstanceConfigurationForm
|
||||
provider={selectedProvider}
|
||||
providerInstance={activeProviderInstance}
|
||||
loading={loading.saving}
|
||||
onSubmit={saveProviderInstance}
|
||||
onDelete={deleteProviderInstance}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<br />
|
||||
<Typography variant="body1" className={styles.textCenter}>
|
||||
Please select a provider.
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AppPaper>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -6,11 +6,9 @@ import {
|
|||
OffsettedListItem,
|
||||
OffsettedListItemCell,
|
||||
} from "@saleor/macaw-ui";
|
||||
import Image from "next/image";
|
||||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
import { SingleProviderSchema } from "../../../lib/cms/config";
|
||||
import { getProviderByName } from "../../providers/config";
|
||||
import { ProviderIcon } from "./provider-icon";
|
||||
|
||||
const useStyles = makeStyles((theme) => {
|
||||
|
|
|
@ -1,20 +1,9 @@
|
|||
import { Grid } from "@material-ui/core";
|
||||
import { Add } from "@material-ui/icons";
|
||||
import { FormControl, Grid, InputLabel, MenuItem, Select } from "@material-ui/core";
|
||||
import { Skeleton } from "@material-ui/lab";
|
||||
import { Button, makeStyles } from "@saleor/macaw-ui";
|
||||
import { SingleProviderSchema } from "../../../lib/cms/config";
|
||||
import { AppPaper } from "../../ui/app-paper";
|
||||
import { ProviderInstancesListItems, ProviderItem } from "./provider-instances-list-items";
|
||||
import { ProvidersErrors, ProvidersLoading } from "./types";
|
||||
|
||||
const useStyles = makeStyles((theme) => {
|
||||
return {
|
||||
button: {
|
||||
padding: theme.spacing(1, 2),
|
||||
justifyContent: "flex-start",
|
||||
},
|
||||
};
|
||||
});
|
||||
import { ProvidersErrors, ProvidersLoading } from "./types";
|
||||
|
||||
const ProviderInstancesListSkeleton = () => {
|
||||
return (
|
||||
|
@ -44,7 +33,7 @@ interface ProviderInstancesListProps {
|
|||
errors: ProvidersErrors;
|
||||
}
|
||||
|
||||
export const ProviderInstancesList = ({
|
||||
export const ProviderInstancesSelect = ({
|
||||
providerInstances,
|
||||
activeProviderInstance,
|
||||
newProviderInstance,
|
||||
|
@ -53,8 +42,6 @@ export const ProviderInstancesList = ({
|
|||
loading,
|
||||
errors,
|
||||
}: ProviderInstancesListProps) => {
|
||||
const styles = useStyles();
|
||||
|
||||
const handleSetActiveProviderInstance = (providerInstance: SingleProviderSchema) => {
|
||||
setActiveProviderInstance(providerInstance);
|
||||
};
|
||||
|
@ -67,27 +54,29 @@ export const ProviderInstancesList = ({
|
|||
return <div>Error loading providers</div>;
|
||||
}
|
||||
|
||||
if (providerInstances.length === 0 || !activeProviderInstance) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid container spacing={1}>
|
||||
{!!providerInstances.length && (
|
||||
<Grid item xs={12}>
|
||||
<ProviderInstancesListItems
|
||||
providerInstances={providerInstances}
|
||||
activeProviderInstance={activeProviderInstance}
|
||||
setActiveProviderInstance={handleSetActiveProviderInstance}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
<Grid item xs={12}>
|
||||
<Button
|
||||
startIcon={<Add />}
|
||||
className={styles.button}
|
||||
fullWidth
|
||||
onClick={requestAddProviderInstance}
|
||||
>
|
||||
Add configuration
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="provider-select">Select Provider to configure</InputLabel>
|
||||
|
||||
<Select
|
||||
labelId="channel-select"
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
value={activeProviderInstance?.id}
|
||||
onChange={(e, value) => {
|
||||
handleSetActiveProviderInstance(providerInstances.find((p) => p.id === e.target.value)!);
|
||||
}}
|
||||
>
|
||||
{providerInstances.map((p) => (
|
||||
<MenuItem key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,19 +1,36 @@
|
|||
import { ProviderInstancesList } from "./provider-instances-list";
|
||||
import { Instructions } from "../../ui/instructions";
|
||||
import { ProviderInstanceConfiguration } from "./provider-instance-configuration";
|
||||
import { providersConfig, ProvidersSchema, SingleProviderSchema } from "../../../lib/cms/config";
|
||||
import { SingleProviderSchema } from "../../../lib/cms/config";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useProviderInstances } from "./hooks/useProviderInstances";
|
||||
import { AppTabs } from "../../ui/app-tabs";
|
||||
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { Button, makeStyles } from "@saleor/macaw-ui";
|
||||
import { ProviderInstancesSelect } from "./provider-instances-list";
|
||||
import { Add } from "@material-ui/icons";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
wrapper: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
},
|
||||
});
|
||||
|
||||
export const ProviderInstances = () => {
|
||||
const styles = useStyles();
|
||||
const { appBridge } = useAppBridge();
|
||||
const { providerInstances, saveProviderInstance, deleteProviderInstance, loading, errors } =
|
||||
useProviderInstances();
|
||||
|
||||
const [activeProviderInstanceId, setActiveProviderInstanceId] = useState<string | null>(
|
||||
providerInstances.length ? providerInstances[0].id : null
|
||||
);
|
||||
const [activeProviderInstanceId, setActiveProviderInstanceId] = useState<string | null>(null);
|
||||
const [newProviderInstance, setNewProviderInstance] = useState<SingleProviderSchema | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (providerInstances.length) {
|
||||
setActiveProviderInstanceId(providerInstances[0].id);
|
||||
}
|
||||
}, [providerInstances]);
|
||||
|
||||
const handleSetActiveProviderInstance = (providerInstance: SingleProviderSchema | null) => {
|
||||
setActiveProviderInstanceId(providerInstance?.id || null);
|
||||
|
||||
|
@ -31,6 +48,14 @@ export const ProviderInstances = () => {
|
|||
const handleSaveProviderInstance = async (providerInstance: SingleProviderSchema) => {
|
||||
const savedProviderInstance = await saveProviderInstance(providerInstance);
|
||||
|
||||
appBridge?.dispatch(
|
||||
actions.Notification({
|
||||
title: "Success",
|
||||
status: "success",
|
||||
text: "Configuration saved",
|
||||
})
|
||||
);
|
||||
|
||||
if (newProviderInstance) {
|
||||
setNewProviderInstance(null);
|
||||
}
|
||||
|
@ -52,24 +77,42 @@ export const ProviderInstances = () => {
|
|||
|
||||
return (
|
||||
<>
|
||||
<ProviderInstancesList
|
||||
providerInstances={providerInstances}
|
||||
activeProviderInstance={activeProviderInstance}
|
||||
newProviderInstance={newProviderInstance}
|
||||
setActiveProviderInstance={handleSetActiveProviderInstance}
|
||||
requestAddProviderInstance={handleAddNewProviderInstance}
|
||||
loading={loading}
|
||||
errors={errors}
|
||||
/>
|
||||
<ProviderInstanceConfiguration
|
||||
activeProviderInstance={activeProviderInstance}
|
||||
newProviderInstance={newProviderInstance}
|
||||
saveProviderInstance={handleSaveProviderInstance}
|
||||
deleteProviderInstance={handleDeleteProviderInstance}
|
||||
loading={loading}
|
||||
errors={errors}
|
||||
/>
|
||||
<Instructions />
|
||||
<AppTabs activeTab="providers" />
|
||||
|
||||
<div className={styles.wrapper}>
|
||||
{!newProviderInstance && (
|
||||
<ProviderInstancesSelect
|
||||
providerInstances={providerInstances}
|
||||
activeProviderInstance={activeProviderInstance}
|
||||
newProviderInstance={newProviderInstance}
|
||||
setActiveProviderInstance={handleSetActiveProviderInstance}
|
||||
requestAddProviderInstance={handleAddNewProviderInstance}
|
||||
loading={loading}
|
||||
errors={errors}
|
||||
/>
|
||||
)}
|
||||
<ProviderInstanceConfiguration
|
||||
activeProviderInstance={activeProviderInstance}
|
||||
newProviderInstance={newProviderInstance}
|
||||
saveProviderInstance={handleSaveProviderInstance}
|
||||
deleteProviderInstance={handleDeleteProviderInstance}
|
||||
loading={loading}
|
||||
errors={errors}
|
||||
onNewProviderRequest={handleAddNewProviderInstance}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
{providerInstances.length > 0 && (
|
||||
<Button
|
||||
size="medium"
|
||||
startIcon={<Add />}
|
||||
fullWidth
|
||||
onClick={handleAddNewProviderInstance}
|
||||
>
|
||||
Add configuration
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import { z } from "zod";
|
||||
import { ContentfulIcon, DatocmsIcon, StrapiIcon } from "../../assets";
|
||||
|
||||
export const CMS_ID_KEY = "cmsId";
|
||||
|
|
|
@ -2,8 +2,7 @@ import { makeStyles } from "@saleor/macaw-ui";
|
|||
|
||||
export const useStyles = makeStyles({
|
||||
root: {
|
||||
maxWidth: 1180,
|
||||
margin: "0 auto",
|
||||
margin: "12px auto",
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ import { PropsWithChildren } from "react";
|
|||
export const useStyles = makeStyles({
|
||||
root: {
|
||||
display: "grid",
|
||||
gridTemplateColumns: "280px auto 280px",
|
||||
gridTemplateColumns: "280px minmax(400px, 600px) 280px",
|
||||
alignItems: "start",
|
||||
gap: 32,
|
||||
},
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { Paper } from "@material-ui/core";
|
||||
import { Paper, PaperProps } from "@material-ui/core";
|
||||
import { makeStyles } from "@saleor/macaw-ui";
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
|
@ -8,10 +9,10 @@ const useStyles = makeStyles({
|
|||
},
|
||||
});
|
||||
|
||||
export const AppPaper = ({ children }: { children: React.ReactNode }) => {
|
||||
export const AppPaper = ({ children, className, ...props }: PaperProps) => {
|
||||
const styles = useStyles();
|
||||
return (
|
||||
<Paper elevation={0} className={styles.root}>
|
||||
<Paper elevation={0} className={clsx(styles.root, className)} {...props}>
|
||||
{children}
|
||||
</Paper>
|
||||
);
|
||||
|
|
|
@ -1,13 +1,34 @@
|
|||
import { makeStyles, PageTab, PageTabs } from "@saleor/macaw-ui";
|
||||
import { makeStyles } from "@saleor/macaw-ui";
|
||||
import { useRouter } from "next/router";
|
||||
import clsx from "clsx";
|
||||
import { ButtonBase, Typography } from "@material-ui/core";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
tabs: {
|
||||
margin: "16px 0",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
button: {
|
||||
background: "#fff",
|
||||
border: "none",
|
||||
fontSize: 14,
|
||||
height: 50,
|
||||
textAlign: "left",
|
||||
cursor: "pointer",
|
||||
borderRadius: 8,
|
||||
padding: "0 20px",
|
||||
|
||||
justifyContent: "flex-start",
|
||||
},
|
||||
active: {
|
||||
border: `1px solid hsla(212, 14%, 77%, 1)`,
|
||||
},
|
||||
});
|
||||
|
||||
const tabs = {
|
||||
home: {
|
||||
label: "Home",
|
||||
},
|
||||
channels: {
|
||||
label: "Channels",
|
||||
},
|
||||
|
@ -32,11 +53,20 @@ export const AppTabs = ({ activeTab }: AppTabsProps) => {
|
|||
|
||||
return (
|
||||
<div className={styles.tabs}>
|
||||
<PageTabs value={activeTab} onChange={handleTabChange}>
|
||||
{Object.entries(tabs).map(([key, config]) => (
|
||||
<PageTab key={key} value={key} label={config.label} />
|
||||
))}
|
||||
</PageTabs>
|
||||
{Object.entries(tabs).map(([key, config]) => (
|
||||
<ButtonBase
|
||||
disableRipple
|
||||
className={clsx(styles.button, {
|
||||
[styles.active]: activeTab === key,
|
||||
})}
|
||||
key={key}
|
||||
onClick={() => {
|
||||
handleTabChange(key);
|
||||
}}
|
||||
>
|
||||
<Typography>{config.label}</Typography>
|
||||
</ButtonBase>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,66 +0,0 @@
|
|||
import { Typography } from "@material-ui/core";
|
||||
import { makeStyles } from "@saleor/macaw-ui";
|
||||
import { AppLink } from "./app-link";
|
||||
import { AppPaper } from "./app-paper";
|
||||
import { AppTabNavButton } from "./app-tab-nav-button";
|
||||
|
||||
const useStyles = makeStyles(() => ({
|
||||
root: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "16px",
|
||||
},
|
||||
list: {
|
||||
paddingLeft: "16px",
|
||||
margin: 0,
|
||||
color: "inherit",
|
||||
},
|
||||
}));
|
||||
|
||||
export const Instructions = () => {
|
||||
const styles = useStyles();
|
||||
return (
|
||||
<AppPaper>
|
||||
<div className={styles.root}>
|
||||
<Typography variant="h4">
|
||||
Use external service for cms product data syncronization
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
<ol className={styles.list}>
|
||||
<li>
|
||||
In the CMS App, go to the <AppTabNavButton to="providers">Providers</AppTabNavButton>{" "}
|
||||
tab to add a configuration of your provider. Click <q>Add configuration</q>, and
|
||||
select the cms provider you want to use. Fill in the configuration form and hit{" "}
|
||||
<q>Save</q>.
|
||||
</li>
|
||||
<li>
|
||||
Go to your CMS website and prepare product variant model shape with:
|
||||
<ul>
|
||||
<li>
|
||||
string fields: <q>saleor_id</q>, <q>name</q>, <q>product_id</q>,{" "}
|
||||
<q>product_name</q>, <q>product_slug</q>,
|
||||
</li>
|
||||
<li>
|
||||
JSON fileds: <q>channels</q>.
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
Go to the <AppTabNavButton to="channels">Channels</AppTabNavButton> tab. Select a
|
||||
channel. Select the CMS configurations you want to sync product variants data against
|
||||
available in this channel and hit <q>Save</q>.
|
||||
</li>
|
||||
<li>
|
||||
Saleor will now use the channel's configured CMS provider for product variant
|
||||
syncronisation once it is created, updated or deleted.
|
||||
</li>
|
||||
<li>
|
||||
To see the effect, go to <AppLink href="/products">Products</AppLink>. Add, update or
|
||||
delete channel listing for any product variant.
|
||||
</li>
|
||||
</ol>
|
||||
</Typography>
|
||||
</div>
|
||||
</AppPaper>
|
||||
);
|
||||
};
|
|
@ -13,7 +13,6 @@ import {
|
|||
} from "../../../lib/cms/client";
|
||||
import { logger as pinoLogger } from "../../../lib/logger";
|
||||
import { createClient } from "../../../lib/graphql";
|
||||
import { fetchProductVariantMetadata } from "../../../lib/metadata";
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
import { AppTabs } from "../modules/ui/app-tabs";
|
||||
import { Channels } from "../modules/channels/ui/channels";
|
||||
import { AppContainer } from "../modules/ui/app-container";
|
||||
import { AppLayout } from "../modules/ui/app-layout";
|
||||
import { NextPageWithLayout } from "./_app";
|
||||
import { ReactElement } from "react";
|
||||
|
@ -10,9 +8,6 @@ const Page: NextPageWithLayout = () => <Channels />;
|
|||
Page.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<main>
|
||||
<AppContainer>
|
||||
<AppTabs activeTab="channels" />
|
||||
</AppContainer>
|
||||
<AppLayout>{page}</AppLayout>
|
||||
</main>
|
||||
);
|
||||
|
|
78
apps/cms/src/pages/home.tsx
Normal file
78
apps/cms/src/pages/home.tsx
Normal file
|
@ -0,0 +1,78 @@
|
|||
import { AppTabs } from "../modules/ui/app-tabs";
|
||||
import { AppLayout } from "../modules/ui/app-layout";
|
||||
import { NextPageWithLayout } from "./_app";
|
||||
import { ReactElement } from "react";
|
||||
|
||||
import { AppTabNavButton } from "../modules/ui/app-tab-nav-button";
|
||||
import { makeStyles } from "@saleor/macaw-ui";
|
||||
import { Box, Typography } from "@material-ui/core";
|
||||
|
||||
const useStyles = makeStyles({
|
||||
section: {
|
||||
border: `1px solid hsla(330, 5%, 91%, 1)`, // todo macaw
|
||||
padding: 20,
|
||||
borderRadius: 8,
|
||||
},
|
||||
dataModelList: {
|
||||
listStyle: "none",
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
"& li": {
|
||||
margin: "1em 0",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const HomePage: NextPageWithLayout = () => {
|
||||
const styles = useStyles();
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppTabs activeTab="home" />
|
||||
<div>
|
||||
<Box>
|
||||
<Typography variant="h3">Connect CMS</Typography>
|
||||
<p>
|
||||
Visit <AppTabNavButton to="providers">providers settings</AppTabNavButton> to configure
|
||||
one of existing providers. Then, connect provider to each{" "}
|
||||
<AppTabNavButton to="channels">channel</AppTabNavButton>.
|
||||
</p>
|
||||
</Box>
|
||||
<div className={styles.section}>
|
||||
<Typography variant="h4">CMS Data Model setup</Typography>
|
||||
<p>CMS App requires your CMS data model to be configured with following structure:</p>
|
||||
<ul className={styles.dataModelList}>
|
||||
<li>
|
||||
<code>saleor_id</code> - string (Variant ID in Saleor)
|
||||
</li>
|
||||
<li>
|
||||
<code>name</code> - string (Variant name in Saleor)
|
||||
</li>
|
||||
<li>
|
||||
<code>product_id</code> - string (Product ID in Saleor)
|
||||
</li>
|
||||
<li>
|
||||
<code>product_name</code> - string (Product name in Saleor)
|
||||
</li>
|
||||
<li>
|
||||
<code>product_slug</code> - string (Product slug in Saleor)
|
||||
</li>
|
||||
<li>
|
||||
<code>channels</code> - JSON (list of channels product belongs to)
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
HomePage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<main>
|
||||
<AppLayout>{page}</AppLayout>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePage;
|
|
@ -1,9 +1,9 @@
|
|||
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { NextPage } from "next";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useIsMounted } from "usehooks-ts";
|
||||
import { isInIframe } from "@saleor/apps-shared";
|
||||
|
||||
/**
|
||||
* This is page publicly accessible from your app.
|
||||
|
@ -16,9 +16,13 @@ const IndexPage: NextPage = () => {
|
|||
|
||||
useEffect(() => {
|
||||
if (isMounted() && appBridgeState?.ready) {
|
||||
replace("/providers");
|
||||
replace("/home");
|
||||
}
|
||||
}, [isMounted, appBridgeState?.ready]);
|
||||
}, [isMounted, appBridgeState?.ready, replace]);
|
||||
|
||||
if (isInIframe()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
import { AppContainer } from "../modules/ui/app-container";
|
||||
import { AppLayout } from "../modules/ui/app-layout";
|
||||
import { AppTabs } from "../modules/ui/app-tabs";
|
||||
import { ProviderInstances } from "../modules/provider-instances/ui/provider-instances";
|
||||
import { NextPageWithLayout } from "./_app";
|
||||
import { ReactElement } from "react";
|
||||
|
@ -10,9 +8,6 @@ const Page: NextPageWithLayout = () => <ProviderInstances />;
|
|||
Page.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<main>
|
||||
<AppContainer>
|
||||
<AppTabs activeTab="providers" />
|
||||
</AppContainer>
|
||||
<AppLayout>{page}</AppLayout>
|
||||
</main>
|
||||
);
|
||||
|
|
|
@ -10,13 +10,7 @@ code {
|
|||
border: 1px solid #eaeaea;
|
||||
border-radius: 5px;
|
||||
display: inline-block;
|
||||
margin-top: 10px;
|
||||
padding: 0.75rem;
|
||||
padding: 0.2em 0.5em;
|
||||
font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
|
||||
Bitstream Vera Sans Mono, Courier New, monospace;
|
||||
}
|
||||
|
||||
code::before {
|
||||
content: "$ ";
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
|
|
@ -38,6 +38,7 @@ importers:
|
|||
'@material-ui/icons': ^4.11.3
|
||||
'@material-ui/lab': 4.0.0-alpha.61
|
||||
'@saleor/app-sdk': 0.37.1
|
||||
'@saleor/apps-shared': workspace:*
|
||||
'@saleor/macaw-ui': ^0.6.7
|
||||
'@sentry/nextjs': ^7.43.0
|
||||
'@testing-library/react': ^13.4.0
|
||||
|
@ -76,6 +77,7 @@ importers:
|
|||
'@material-ui/icons': 4.11.3_x54wk6dsnsxe7g7vvfmytp77te
|
||||
'@material-ui/lab': 4.0.0-alpha.61_x54wk6dsnsxe7g7vvfmytp77te
|
||||
'@saleor/app-sdk': 0.37.1_ld2jel3hspngo3u5lti2kgl2sq
|
||||
'@saleor/apps-shared': link:../../packages/shared
|
||||
'@saleor/macaw-ui': 0.6.7_pmlnlm755hlzzzocw2qhf3a34e
|
||||
'@sentry/nextjs': 7.43.0_next@13.2.4+react@18.2.0
|
||||
'@urql/exchange-auth': 1.0.0_graphql@16.6.0
|
||||
|
|
Loading…
Reference in a new issue