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:
Lukasz Ostrowski 2023-04-06 12:56:44 +02:00 committed by GitHub
parent 3acc1553a8
commit 1da51639fa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 436 additions and 292 deletions

View file

@ -0,0 +1,5 @@
---
"saleor-app-cms": minor
---
Redesigned the app to better match new Dashboard

View file

@ -14,6 +14,7 @@ module.exports = {
disableServerWebpackPlugin: !isSentryPropertiesInEnvironment, disableServerWebpackPlugin: !isSentryPropertiesInEnvironment,
disableClientWebpackPlugin: !isSentryPropertiesInEnvironment, disableClientWebpackPlugin: !isSentryPropertiesInEnvironment,
}, },
transpilePackages: ["@saleor/apps-shared"],
}; };
module.exports = withSentryConfig(module.exports, { silent: true }, { hideSourcemaps: true }); module.exports = withSentryConfig(module.exports, { silent: true }, { hideSourcemaps: true });

View file

@ -38,7 +38,8 @@
"usehooks-ts": "^2.9.1", "usehooks-ts": "^2.9.1",
"uuid": "^9.0.0", "uuid": "^9.0.0",
"vite": "^4.1.4", "vite": "^4.1.4",
"zod": "^3.19.1" "zod": "^3.19.1",
"@saleor/apps-shared": "workspace:*"
}, },
"devDependencies": { "devDependencies": {
"@graphql-codegen/cli": "2.13.3", "@graphql-codegen/cli": "2.13.3",

View file

@ -1,14 +1,13 @@
import { NextWebhookApiHandler } from "@saleor/app-sdk/handlers/next"; import { NextWebhookApiHandler } from "@saleor/app-sdk/handlers/next";
import { createClient } from "../../graphql";
import { createSettingsManager } from "../../metadata"; import { createSettingsManager } from "../../metadata";
import { getOperationType } from "./operations"; import { getOperationType } from "./operations";
import { import {
getChannelsSettings, getChannelsSettings,
getProviderInstancesSettings,
getProductVariantProviderInstancesToAlter, getProductVariantProviderInstancesToAlter,
getProviderInstancesSettings,
} from "./settings"; } from "./settings";
import { providersSchemaSet } from "../config"; import { providersSchemaSet } from "../config";
import { cmsProviders, CMSProvider } from "../providers"; import { CMSProvider, cmsProviders } from "../providers";
import { CmsClientOperations } from "../types"; import { CmsClientOperations } from "../types";
import { logger as pinoLogger } from "../../logger"; import { logger as pinoLogger } from "../../logger";
import { getCmsIdFromSaleorItemKey } from "./metadata"; import { getCmsIdFromSaleorItemKey } from "./metadata";

View file

@ -1,8 +1,6 @@
import { AuthData } from "@saleor/app-sdk/APL";
import { NextWebhookApiHandler } from "@saleor/app-sdk/handlers/next"; import { NextWebhookApiHandler } from "@saleor/app-sdk/handlers/next";
import { import {
DeleteMetadataDocument, DeleteMetadataDocument,
ProductVariantUpdatedWebhookPayloadFragment,
UpdateMetadataDocument, UpdateMetadataDocument,
WebhookProductVariantFragment, WebhookProductVariantFragment,
} from "../../../../generated/graphql"; } from "../../../../generated/graphql";

View file

@ -1,6 +1,5 @@
import { EncryptedMetadataManager } from "@saleor/app-sdk/settings-manager"; import { EncryptedMetadataManager } from "@saleor/app-sdk/settings-manager";
import { CMSSchemaChannels, CMSSchemaProviderInstances } from "../config"; import { CMSSchemaChannels, CMSSchemaProviderInstances } from "../config";
import { createCmsKeyForSaleorItem, getCmsIdFromSaleorItemKey } from "./metadata";
export const getChannelsSettings = async (settingsManager: EncryptedMetadataManager) => { export const getChannelsSettings = async (settingsManager: EncryptedMetadataManager) => {
const channelsSettings = await settingsManager.get("channels"); const channelsSettings = await settingsManager.get("channels");

View file

@ -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 { Client } from "urql";
import { import {

View file

@ -1,18 +1,17 @@
import { zodResolver } from "@hookform/resolvers/zod"; 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 { import {
makeStyles, Button,
List, List,
ListBody, ListBody,
ListFooter,
ListHeader, ListHeader,
ListItem, ListItem,
ListItemCell, ListItemCell,
ListFooter, makeStyles,
Button,
} from "@saleor/macaw-ui"; } from "@saleor/macaw-ui";
import React from "react"; import React from "react";
import { Controller, useController, useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { z } from "zod";
import { import {
channelSchema, channelSchema,
ChannelSchema, ChannelSchema,
@ -39,6 +38,10 @@ const useStyles = makeStyles((theme) => {
justifyContent: "flex-end", justifyContent: "flex-end",
padding: theme.spacing(2, 4), 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; const errors = formState.errors;
return ( return (
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)} className={styles.form}>
{!!Object.entries(errors).length && ( {!!Object.entries(errors).length && (
<Typography variant="body1" color="error"> <Typography variant="body1" color="error">
Error validating form Error validating form

View file

@ -1,10 +1,9 @@
import { AppPaper } from "../../ui/app-paper"; 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 { Skeleton } from "@material-ui/lab";
import { ChannelConfigurationForm } from "./channel-configuration-form"; import { ChannelConfigurationForm } from "./channel-configuration-form";
import { import {
MergedChannelSchema, MergedChannelSchema,
ProvidersSchema,
SingleChannelSchema, SingleChannelSchema,
SingleProviderSchema, SingleProviderSchema,
} from "../../../lib/cms/config"; } from "../../../lib/cms/config";

View file

@ -1,5 +1,5 @@
import { makeStyles } from "@saleor/macaw-ui";
import { import {
makeStyles,
OffsettedList, OffsettedList,
OffsettedListBody, OffsettedListBody,
OffsettedListHeader, OffsettedListHeader,
@ -7,9 +7,7 @@ import {
OffsettedListItemCell, OffsettedListItemCell,
} from "@saleor/macaw-ui"; } from "@saleor/macaw-ui";
import clsx from "clsx"; import clsx from "clsx";
import { ChannelFragment } from "../../../../generated/graphql"; import { MergedChannelSchema } from "../../../lib/cms/config";
import { MergedChannelSchema, SingleChannelSchema } from "../../../lib/cms/config";
import { ProviderIcon } from "../../provider-instances/ui/provider-icon";
const useStyles = makeStyles((theme) => { const useStyles = makeStyles((theme) => {
return { return {

View file

@ -1,25 +1,14 @@
import { Grid } from "@material-ui/core";
import { Skeleton } from "@material-ui/lab"; import { Skeleton } from "@material-ui/lab";
import { ChannelFragment } from "../../../../generated/graphql"; import { MergedChannelSchema } from "../../../lib/cms";
import { MergedChannelSchema, SingleChannelSchema } from "../../../lib/cms";
import { AppPaper } from "../../ui/app-paper"; import { AppPaper } from "../../ui/app-paper";
import { ChannelsListItems } from "./channels-list-items";
import { ChannelsErrors, ChannelsLoading } from "./types"; import { ChannelsErrors, ChannelsLoading } from "./types";
import { ChannelsSelect } from "./channels-select";
const ChannelsListSkeleton = () => { const ChannelsListSkeleton = () => {
return ( return (
<AppPaper> <AppPaper>
<Grid container spacing={2}> <Skeleton variant="rect" width={"100%"} height={30} />
<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>
</AppPaper> </AppPaper>
); );
}; };
@ -48,7 +37,7 @@ export const ChannelsList = ({
} }
return ( return (
<ChannelsListItems <ChannelsSelect
channels={channels} channels={channels}
activeChannel={activeChannel} activeChannel={activeChannel}
setActiveChannel={setActiveChannel} setActiveChannel={setActiveChannel}

View 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>
);
};

View file

@ -1,12 +1,22 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { MergedChannelSchema } from "../../../lib/cms/config"; import { MergedChannelSchema } from "../../../lib/cms/config";
import { useProviderInstances } from "../../provider-instances/ui/hooks/useProviderInstances"; import { useProviderInstances } from "../../provider-instances/ui/hooks/useProviderInstances";
import { Instructions } from "../../ui/instructions";
import { ChannelConfiguration } from "./channel-configuration"; import { ChannelConfiguration } from "./channel-configuration";
import { ChannelsList } from "./channels-list"; import { ChannelsList } from "./channels-list";
import { useChannels } from "./hooks/useChannels"; 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 = () => { export const Channels = () => {
const styles = useStyles();
const { channels, saveChannel, loading, errors } = useChannels(); const { channels, saveChannel, loading, errors } = useChannels();
const { providerInstances } = useProviderInstances(); const { providerInstances } = useProviderInstances();
@ -20,23 +30,32 @@ export const Channels = () => {
const activeChannel = channels.find((channel) => channel.channelSlug === activeChannelSlug); const activeChannel = channels.find((channel) => channel.channelSlug === activeChannelSlug);
useEffect(() => {
if (!activeChannelSlug && channels.length > 0) {
setActiveChannelSlug(channels[0].channelSlug);
}
}, [channels]);
return ( return (
<> <>
<ChannelsList <AppTabs activeTab="channels" />
channels={channels}
activeChannel={activeChannel} <div className={styles.wrapper}>
setActiveChannel={handleSetActiveChannel} <ChannelsList
loading={loading} channels={channels}
errors={errors} activeChannel={activeChannel}
/> setActiveChannel={handleSetActiveChannel}
<ChannelConfiguration loading={loading}
activeChannel={activeChannel} errors={errors}
providerInstances={providerInstances} />
saveChannel={saveChannel} <ChannelConfiguration
loading={loading} activeChannel={activeChannel}
errors={errors} providerInstances={providerInstances}
/> saveChannel={saveChannel}
<Instructions /> loading={loading}
errors={errors}
/>
</div>
</> </>
); );
}; };

View file

@ -3,8 +3,10 @@ import { MergedChannelSchema, SingleChannelSchema } from "../../../../lib/cms/co
import { ChannelsErrors, ChannelsLoading } from "../types"; import { ChannelsErrors, ChannelsLoading } from "../types";
import { useChannelsQuery } from "../../../../../generated/graphql"; import { useChannelsQuery } from "../../../../../generated/graphql";
import { useIsMounted } from "usehooks-ts"; import { useIsMounted } from "usehooks-ts";
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
export const useChannels = () => { export const useChannels = () => {
const { appBridge } = useAppBridge();
const isMounted = useIsMounted(); const isMounted = useIsMounted();
const [channelsQueryData, channelsQueryOpts] = useChannelsQuery({ const [channelsQueryData, channelsQueryOpts] = useChannelsQuery({
pause: !isMounted, pause: !isMounted,
@ -20,7 +22,15 @@ export const useChannels = () => {
const saveChannel = (channelToSave: SingleChannelSchema) => { const saveChannel = (channelToSave: SingleChannelSchema) => {
console.log("saveChannel", channelToSave); console.log("saveChannel", channelToSave);
saveChannelFetch(channelToSave); saveChannelFetch(channelToSave).then(() => {
appBridge?.dispatch(
actions.Notification({
title: "Success",
status: "success",
text: "Configuration saved",
})
);
});
}; };
const loading: ChannelsLoading = { const loading: ChannelsLoading = {

View file

@ -1,10 +1,6 @@
import { useAppBridge } from "@saleor/app-sdk/app-bridge"; import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import React from "react"; import React from "react";
import { import { CMSSchemaChannels, SingleChannelSchema } from "../../../../lib/cms/config";
CMSSchemaChannels,
cmsSchemaChannels,
SingleChannelSchema,
} from "../../../../lib/cms/config";
import { SALEOR_API_URL_HEADER, SALEOR_AUTHORIZATION_BEARER_HEADER } from "@saleor/app-sdk/const"; import { SALEOR_API_URL_HEADER, SALEOR_AUTHORIZATION_BEARER_HEADER } from "@saleor/app-sdk/const";
import { ChannelsApiResponse } from "../../../../pages/api/channels"; import { ChannelsApiResponse } from "../../../../pages/api/channels";

View file

@ -13,8 +13,6 @@ export const useProviderInstances = () => {
} = useProviderInstancesFetch(); } = useProviderInstancesFetch();
const saveProviderInstance = async (providerInstanceToSave: SingleProviderSchema) => { const saveProviderInstance = async (providerInstanceToSave: SingleProviderSchema) => {
console.log("saveProviderInstance", providerInstanceToSave);
return await saveProviderInstanceFetch(providerInstanceToSave); return await saveProviderInstanceFetch(providerInstanceToSave);
}; };

View file

@ -55,6 +55,8 @@ export const useProviderInstancesFetch = () => {
}; };
const saveProviderInstance = async (instance: SingleProviderSchema) => { const saveProviderInstance = async (instance: SingleProviderSchema) => {
console.log(instance);
try { try {
setIsSaving(true); setIsSaving(true);
const response = await fetch("/api/provider-instances", { const response = await fetch("/api/provider-instances", {

View file

@ -2,16 +2,13 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { Grid, TextField, Typography } from "@material-ui/core"; import { Grid, TextField, Typography } from "@material-ui/core";
import { Button, makeStyles } from "@saleor/macaw-ui"; import { Button, makeStyles } from "@saleor/macaw-ui";
import React from "react"; import React from "react";
import ReactMarkdown from "react-markdown";
import { Path, useForm } from "react-hook-form"; import { Path, useForm } from "react-hook-form";
import { z } from "zod";
import { import {
providersConfig,
CMSProviderSchema, CMSProviderSchema,
providersSchemaSet, providersConfig,
ProvidersSchema, ProvidersSchema,
providersSchemaSet,
SingleProviderSchema, SingleProviderSchema,
ProviderInstanceSchema,
} from "../../../lib/cms/config"; } from "../../../lib/cms/config";
import { Provider } from "../../providers/config"; import { Provider } from "../../providers/config";
import { AppMarkdownText } from "../../ui/app-markdown-text"; import { AppMarkdownText } from "../../ui/app-markdown-text";
@ -68,7 +65,6 @@ export const ProviderInstanceConfigurationForm = <TProvider extends CMSProviderS
}); });
if (providerInstance && providerInstance.providerName === provider.name) { if (providerInstance && providerInstance.providerName === provider.name) {
console.log(providerInstance);
reset(providerInstance as ProvidersSchema[TProvider]); reset(providerInstance as ProvidersSchema[TProvider]);
} }
}, [provider, providerInstance]); }, [provider, providerInstance]);

View file

@ -1,5 +1,5 @@
import { FormControlLabel, Grid, Radio, RadioGroup, Typography } from "@material-ui/core"; 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 Image from "next/image";
import React from "react"; import React from "react";
import { CMSProviderSchema, providersConfig, SingleProviderSchema } from "../../../lib/cms/config"; import { CMSProviderSchema, providersConfig, SingleProviderSchema } from "../../../lib/cms/config";
@ -39,6 +39,18 @@ const useStyles = makeStyles((theme) => ({
textAlign: "center", textAlign: "center",
margin: theme.spacing(1, 0, 3, 0), 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 = () => { const ProviderInstanceConfigurationSkeleton = () => {
@ -82,6 +94,7 @@ interface ProviderInstanceConfigurationProps {
deleteProviderInstance: (providerInstance: SingleProviderSchema) => any; deleteProviderInstance: (providerInstance: SingleProviderSchema) => any;
loading: ProvidersLoading; loading: ProvidersLoading;
errors: ProvidersErrors; errors: ProvidersErrors;
onNewProviderRequest(): void;
} }
export const ProviderInstanceConfiguration = ({ export const ProviderInstanceConfiguration = ({
@ -90,6 +103,7 @@ export const ProviderInstanceConfiguration = ({
saveProviderInstance, saveProviderInstance,
deleteProviderInstance, deleteProviderInstance,
loading, loading,
onNewProviderRequest,
errors, errors,
}: ProviderInstanceConfigurationProps) => { }: ProviderInstanceConfigurationProps) => {
const [selectedProvider, setSelectedProvider] = React.useState<Provider | undefined>( const [selectedProvider, setSelectedProvider] = React.useState<Provider | undefined>(
@ -120,70 +134,86 @@ export const ProviderInstanceConfiguration = ({
<Typography variant="body1" className={styles.textCenter}> <Typography variant="body1" className={styles.textCenter}>
Please select a provider configuration or add new one. Please select a provider configuration or add new one.
</Typography> </Typography>
<div className={styles.newProviderContainer}>
<Button
onClick={onNewProviderRequest}
variant="primary"
className={styles.newProviderButton}
>
Create a Provider config
</Button>
</div>
</AppPaper> </AppPaper>
); );
} }
return ( return (
<AppPaper> <AppPaper className={styles.box}>
{errors.fetching && ( <div>
<Typography variant="body1" color="error"> {errors.fetching && (
Error fetching available providers <Typography variant="body1" color="error">
</Typography> Error fetching available providers
)}
{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> </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> </AppPaper>
); );
}; };

View file

@ -6,11 +6,9 @@ import {
OffsettedListItem, OffsettedListItem,
OffsettedListItemCell, OffsettedListItemCell,
} from "@saleor/macaw-ui"; } from "@saleor/macaw-ui";
import Image from "next/image";
import clsx from "clsx"; import clsx from "clsx";
import React from "react"; import React from "react";
import { SingleProviderSchema } from "../../../lib/cms/config"; import { SingleProviderSchema } from "../../../lib/cms/config";
import { getProviderByName } from "../../providers/config";
import { ProviderIcon } from "./provider-icon"; import { ProviderIcon } from "./provider-icon";
const useStyles = makeStyles((theme) => { const useStyles = makeStyles((theme) => {

View file

@ -1,20 +1,9 @@
import { Grid } from "@material-ui/core"; import { FormControl, Grid, InputLabel, MenuItem, Select } from "@material-ui/core";
import { Add } from "@material-ui/icons";
import { Skeleton } from "@material-ui/lab"; import { Skeleton } from "@material-ui/lab";
import { Button, makeStyles } from "@saleor/macaw-ui";
import { SingleProviderSchema } from "../../../lib/cms/config"; import { SingleProviderSchema } from "../../../lib/cms/config";
import { AppPaper } from "../../ui/app-paper"; import { AppPaper } from "../../ui/app-paper";
import { ProviderInstancesListItems, ProviderItem } from "./provider-instances-list-items";
import { ProvidersErrors, ProvidersLoading } from "./types";
const useStyles = makeStyles((theme) => { import { ProvidersErrors, ProvidersLoading } from "./types";
return {
button: {
padding: theme.spacing(1, 2),
justifyContent: "flex-start",
},
};
});
const ProviderInstancesListSkeleton = () => { const ProviderInstancesListSkeleton = () => {
return ( return (
@ -44,7 +33,7 @@ interface ProviderInstancesListProps {
errors: ProvidersErrors; errors: ProvidersErrors;
} }
export const ProviderInstancesList = ({ export const ProviderInstancesSelect = ({
providerInstances, providerInstances,
activeProviderInstance, activeProviderInstance,
newProviderInstance, newProviderInstance,
@ -53,8 +42,6 @@ export const ProviderInstancesList = ({
loading, loading,
errors, errors,
}: ProviderInstancesListProps) => { }: ProviderInstancesListProps) => {
const styles = useStyles();
const handleSetActiveProviderInstance = (providerInstance: SingleProviderSchema) => { const handleSetActiveProviderInstance = (providerInstance: SingleProviderSchema) => {
setActiveProviderInstance(providerInstance); setActiveProviderInstance(providerInstance);
}; };
@ -67,27 +54,29 @@ export const ProviderInstancesList = ({
return <div>Error loading providers</div>; return <div>Error loading providers</div>;
} }
if (providerInstances.length === 0 || !activeProviderInstance) {
return null;
}
return ( return (
<Grid container spacing={1}> <FormControl fullWidth>
{!!providerInstances.length && ( <InputLabel id="provider-select">Select Provider to configure</InputLabel>
<Grid item xs={12}>
<ProviderInstancesListItems <Select
providerInstances={providerInstances} labelId="channel-select"
activeProviderInstance={activeProviderInstance} variant="outlined"
setActiveProviderInstance={handleSetActiveProviderInstance} fullWidth
/> value={activeProviderInstance?.id}
</Grid> onChange={(e, value) => {
)} handleSetActiveProviderInstance(providerInstances.find((p) => p.id === e.target.value)!);
<Grid item xs={12}> }}
<Button >
startIcon={<Add />} {providerInstances.map((p) => (
className={styles.button} <MenuItem key={p.id} value={p.id}>
fullWidth {p.name}
onClick={requestAddProviderInstance} </MenuItem>
> ))}
Add configuration </Select>
</Button> </FormControl>
</Grid>
</Grid>
); );
}; };

View file

@ -1,19 +1,36 @@
import { ProviderInstancesList } from "./provider-instances-list";
import { Instructions } from "../../ui/instructions";
import { ProviderInstanceConfiguration } from "./provider-instance-configuration"; 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 { useEffect, useState } from "react";
import { useProviderInstances } from "./hooks/useProviderInstances"; 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 = () => { export const ProviderInstances = () => {
const styles = useStyles();
const { appBridge } = useAppBridge();
const { providerInstances, saveProviderInstance, deleteProviderInstance, loading, errors } = const { providerInstances, saveProviderInstance, deleteProviderInstance, loading, errors } =
useProviderInstances(); useProviderInstances();
const [activeProviderInstanceId, setActiveProviderInstanceId] = useState<string | null>( const [activeProviderInstanceId, setActiveProviderInstanceId] = useState<string | null>(null);
providerInstances.length ? providerInstances[0].id : null
);
const [newProviderInstance, setNewProviderInstance] = useState<SingleProviderSchema | null>(null); const [newProviderInstance, setNewProviderInstance] = useState<SingleProviderSchema | null>(null);
useEffect(() => {
if (providerInstances.length) {
setActiveProviderInstanceId(providerInstances[0].id);
}
}, [providerInstances]);
const handleSetActiveProviderInstance = (providerInstance: SingleProviderSchema | null) => { const handleSetActiveProviderInstance = (providerInstance: SingleProviderSchema | null) => {
setActiveProviderInstanceId(providerInstance?.id || null); setActiveProviderInstanceId(providerInstance?.id || null);
@ -31,6 +48,14 @@ export const ProviderInstances = () => {
const handleSaveProviderInstance = async (providerInstance: SingleProviderSchema) => { const handleSaveProviderInstance = async (providerInstance: SingleProviderSchema) => {
const savedProviderInstance = await saveProviderInstance(providerInstance); const savedProviderInstance = await saveProviderInstance(providerInstance);
appBridge?.dispatch(
actions.Notification({
title: "Success",
status: "success",
text: "Configuration saved",
})
);
if (newProviderInstance) { if (newProviderInstance) {
setNewProviderInstance(null); setNewProviderInstance(null);
} }
@ -52,24 +77,42 @@ export const ProviderInstances = () => {
return ( return (
<> <>
<ProviderInstancesList <AppTabs activeTab="providers" />
providerInstances={providerInstances}
activeProviderInstance={activeProviderInstance} <div className={styles.wrapper}>
newProviderInstance={newProviderInstance} {!newProviderInstance && (
setActiveProviderInstance={handleSetActiveProviderInstance} <ProviderInstancesSelect
requestAddProviderInstance={handleAddNewProviderInstance} providerInstances={providerInstances}
loading={loading} activeProviderInstance={activeProviderInstance}
errors={errors} newProviderInstance={newProviderInstance}
/> setActiveProviderInstance={handleSetActiveProviderInstance}
<ProviderInstanceConfiguration requestAddProviderInstance={handleAddNewProviderInstance}
activeProviderInstance={activeProviderInstance} loading={loading}
newProviderInstance={newProviderInstance} errors={errors}
saveProviderInstance={handleSaveProviderInstance} />
deleteProviderInstance={handleDeleteProviderInstance} )}
loading={loading} <ProviderInstanceConfiguration
errors={errors} activeProviderInstance={activeProviderInstance}
/> newProviderInstance={newProviderInstance}
<Instructions /> 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>
</> </>
); );
}; };

View file

@ -1,4 +1,3 @@
import { z } from "zod";
import { ContentfulIcon, DatocmsIcon, StrapiIcon } from "../../assets"; import { ContentfulIcon, DatocmsIcon, StrapiIcon } from "../../assets";
export const CMS_ID_KEY = "cmsId"; export const CMS_ID_KEY = "cmsId";

View file

@ -2,8 +2,7 @@ import { makeStyles } from "@saleor/macaw-ui";
export const useStyles = makeStyles({ export const useStyles = makeStyles({
root: { root: {
maxWidth: 1180, margin: "12px auto",
margin: "0 auto",
}, },
}); });

View file

@ -4,7 +4,7 @@ import { PropsWithChildren } from "react";
export const useStyles = makeStyles({ export const useStyles = makeStyles({
root: { root: {
display: "grid", display: "grid",
gridTemplateColumns: "280px auto 280px", gridTemplateColumns: "280px minmax(400px, 600px) 280px",
alignItems: "start", alignItems: "start",
gap: 32, gap: 32,
}, },

View file

@ -1,6 +1,7 @@
import { Paper } from "@material-ui/core"; import { Paper, PaperProps } from "@material-ui/core";
import { makeStyles } from "@saleor/macaw-ui"; import { makeStyles } from "@saleor/macaw-ui";
import React from "react"; import React from "react";
import clsx from "clsx";
const useStyles = makeStyles({ const useStyles = makeStyles({
root: { 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(); const styles = useStyles();
return ( return (
<Paper elevation={0} className={styles.root}> <Paper elevation={0} className={clsx(styles.root, className)} {...props}>
{children} {children}
</Paper> </Paper>
); );

View file

@ -1,13 +1,34 @@
import { makeStyles, PageTab, PageTabs } from "@saleor/macaw-ui"; import { makeStyles } from "@saleor/macaw-ui";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import clsx from "clsx";
import { ButtonBase, Typography } from "@material-ui/core";
const useStyles = makeStyles({ const useStyles = makeStyles({
tabs: { 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 = { const tabs = {
home: {
label: "Home",
},
channels: { channels: {
label: "Channels", label: "Channels",
}, },
@ -32,11 +53,20 @@ export const AppTabs = ({ activeTab }: AppTabsProps) => {
return ( return (
<div className={styles.tabs}> <div className={styles.tabs}>
<PageTabs value={activeTab} onChange={handleTabChange}> {Object.entries(tabs).map(([key, config]) => (
{Object.entries(tabs).map(([key, config]) => ( <ButtonBase
<PageTab key={key} value={key} label={config.label} /> disableRipple
))} className={clsx(styles.button, {
</PageTabs> [styles.active]: activeTab === key,
})}
key={key}
onClick={() => {
handleTabChange(key);
}}
>
<Typography>{config.label}</Typography>
</ButtonBase>
))}
</div> </div>
); );
}; };

View file

@ -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&#39;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>
);
};

View file

@ -13,7 +13,6 @@ import {
} from "../../../lib/cms/client"; } from "../../../lib/cms/client";
import { logger as pinoLogger } from "../../../lib/logger"; import { logger as pinoLogger } from "../../../lib/logger";
import { createClient } from "../../../lib/graphql"; import { createClient } from "../../../lib/graphql";
import { fetchProductVariantMetadata } from "../../../lib/metadata";
export const config = { export const config = {
api: { api: {

View file

@ -1,6 +1,4 @@
import { AppTabs } from "../modules/ui/app-tabs";
import { Channels } from "../modules/channels/ui/channels"; import { Channels } from "../modules/channels/ui/channels";
import { AppContainer } from "../modules/ui/app-container";
import { AppLayout } from "../modules/ui/app-layout"; import { AppLayout } from "../modules/ui/app-layout";
import { NextPageWithLayout } from "./_app"; import { NextPageWithLayout } from "./_app";
import { ReactElement } from "react"; import { ReactElement } from "react";
@ -10,9 +8,6 @@ const Page: NextPageWithLayout = () => <Channels />;
Page.getLayout = function getLayout(page: ReactElement) { Page.getLayout = function getLayout(page: ReactElement) {
return ( return (
<main> <main>
<AppContainer>
<AppTabs activeTab="channels" />
</AppContainer>
<AppLayout>{page}</AppLayout> <AppLayout>{page}</AppLayout>
</main> </main>
); );

View 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;

View file

@ -1,9 +1,9 @@
import { useAppBridge } from "@saleor/app-sdk/app-bridge"; import { useAppBridge } from "@saleor/app-sdk/app-bridge";
import { NextPage } from "next"; import { NextPage } from "next";
import dynamic from "next/dynamic";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect, useState } from "react"; import { useEffect } from "react";
import { useIsMounted } from "usehooks-ts"; import { useIsMounted } from "usehooks-ts";
import { isInIframe } from "@saleor/apps-shared";
/** /**
* This is page publicly accessible from your app. * This is page publicly accessible from your app.
@ -16,9 +16,13 @@ const IndexPage: NextPage = () => {
useEffect(() => { useEffect(() => {
if (isMounted() && appBridgeState?.ready) { if (isMounted() && appBridgeState?.ready) {
replace("/providers"); replace("/home");
} }
}, [isMounted, appBridgeState?.ready]); }, [isMounted, appBridgeState?.ready, replace]);
if (isInIframe()) {
return null;
}
return ( return (
<div> <div>

View file

@ -1,6 +1,4 @@
import { AppContainer } from "../modules/ui/app-container";
import { AppLayout } from "../modules/ui/app-layout"; import { AppLayout } from "../modules/ui/app-layout";
import { AppTabs } from "../modules/ui/app-tabs";
import { ProviderInstances } from "../modules/provider-instances/ui/provider-instances"; import { ProviderInstances } from "../modules/provider-instances/ui/provider-instances";
import { NextPageWithLayout } from "./_app"; import { NextPageWithLayout } from "./_app";
import { ReactElement } from "react"; import { ReactElement } from "react";
@ -10,9 +8,6 @@ const Page: NextPageWithLayout = () => <ProviderInstances />;
Page.getLayout = function getLayout(page: ReactElement) { Page.getLayout = function getLayout(page: ReactElement) {
return ( return (
<main> <main>
<AppContainer>
<AppTabs activeTab="providers" />
</AppContainer>
<AppLayout>{page}</AppLayout> <AppLayout>{page}</AppLayout>
</main> </main>
); );

View file

@ -10,13 +10,7 @@ code {
border: 1px solid #eaeaea; border: 1px solid #eaeaea;
border-radius: 5px; border-radius: 5px;
display: inline-block; display: inline-block;
margin-top: 10px; padding: 0.2em 0.5em;
padding: 0.75rem;
font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
Bitstream Vera Sans Mono, Courier New, monospace; Bitstream Vera Sans Mono, Courier New, monospace;
} }
code::before {
content: "$ ";
opacity: 0.6;
}

View file

@ -38,6 +38,7 @@ importers:
'@material-ui/icons': ^4.11.3 '@material-ui/icons': ^4.11.3
'@material-ui/lab': 4.0.0-alpha.61 '@material-ui/lab': 4.0.0-alpha.61
'@saleor/app-sdk': 0.37.1 '@saleor/app-sdk': 0.37.1
'@saleor/apps-shared': workspace:*
'@saleor/macaw-ui': ^0.6.7 '@saleor/macaw-ui': ^0.6.7
'@sentry/nextjs': ^7.43.0 '@sentry/nextjs': ^7.43.0
'@testing-library/react': ^13.4.0 '@testing-library/react': ^13.4.0
@ -76,6 +77,7 @@ importers:
'@material-ui/icons': 4.11.3_x54wk6dsnsxe7g7vvfmytp77te '@material-ui/icons': 4.11.3_x54wk6dsnsxe7g7vvfmytp77te
'@material-ui/lab': 4.0.0-alpha.61_x54wk6dsnsxe7g7vvfmytp77te '@material-ui/lab': 4.0.0-alpha.61_x54wk6dsnsxe7g7vvfmytp77te
'@saleor/app-sdk': 0.37.1_ld2jel3hspngo3u5lti2kgl2sq '@saleor/app-sdk': 0.37.1_ld2jel3hspngo3u5lti2kgl2sq
'@saleor/apps-shared': link:../../packages/shared
'@saleor/macaw-ui': 0.6.7_pmlnlm755hlzzzocw2qhf3a34e '@saleor/macaw-ui': 0.6.7_pmlnlm755hlzzzocw2qhf3a34e
'@sentry/nextjs': 7.43.0_next@13.2.4+react@18.2.0 '@sentry/nextjs': 7.43.0_next@13.2.4+react@18.2.0
'@urql/exchange-auth': 1.0.0_graphql@16.6.0 '@urql/exchange-auth': 1.0.0_graphql@16.6.0