Add dashboard extensions (#1292)

* Fix app embed

* initial setup

* Create ButtonWithSelect component

* Use context

* Use dialog

* Handle app-bridge actions

* Code adjustments, bump packages

* Fix tests

* Fix schema formatting

* Send response

* Use latest package versions
This commit is contained in:
Jakub Majorek 2021-08-20 15:58:53 +02:00 committed by GitHub
parent 281bf7f613
commit ab4c897b2a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 899 additions and 205 deletions

33
package-lock.json generated
View file

@ -69,6 +69,17 @@
"tslib": "^1.10.0"
}
},
"@apollo/react-testing": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/@apollo/react-testing/-/react-testing-3.1.4.tgz",
"integrity": "sha512-1eKjN36UfIAnBVmfLbl12vQ/eCjTqYdaU95chGIQzT2uHd5BnasJu0z+MwXBrEs57A9WY9mFvLZxdjzQJXaacA==",
"dev": true,
"requires": {
"@apollo/react-common": "^3.1.4",
"fast-json-stable-stringify": "^2.0.0",
"tslib": "^1.10.0"
}
},
"@apollographql/apollo-tools": {
"version": "0.4.9",
"resolved": "https://registry.npmjs.org/@apollographql/apollo-tools/-/apollo-tools-0.4.9.tgz",
@ -4181,9 +4192,27 @@
"picomatch": "^2.2.2"
}
},
"@saleor/app-bridge": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/@saleor/app-bridge/-/app-bridge-0.1.5.tgz",
"integrity": "sha512-AkttPM/lLY1TOKjxoynKald659lb5+56NBwU22YEYWitguQDRR3zPrdti0KuzCkQ8CL99NeNo7JGr8T314kjVA==",
"dev": true,
"requires": {
"uuid": "^8.3.2"
},
"dependencies": {
"uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"dev": true
}
}
},
"@saleor/macaw-ui": {
"version": "github:mirumee/macaw-ui#03d63cd1342d2dc7fe18e9f8a9249ba27a52d9c2",
"from": "github:mirumee/macaw-ui#03d63cd",
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/@saleor/macaw-ui/-/macaw-ui-0.2.3.tgz",
"integrity": "sha512-9vcjY27lbxmySEnesJIefjjZAb/H8Hqm0AtCLUx6gZA2gvUkmJWQuYqcUljTY5VKEIuBpsG9BJtItRTWhQUG9g==",
"requires": {
"clsx": "^1.1.1",
"lodash": "^4.17.21",

View file

@ -27,7 +27,7 @@
"@material-ui/icons": "^4.11.2",
"@material-ui/lab": "^4.0.0-alpha.58",
"@material-ui/styles": "^4.11.4",
"@saleor/macaw-ui": "github:mirumee/macaw-ui#03d63cd",
"@saleor/macaw-ui": "^0.2.3",
"@sentry/react": "^6.0.0",
"@types/faker": "^5.1.6",
"apollo": "^2.32.5",
@ -82,6 +82,7 @@
"use-react-router": "^1.0.7"
},
"devDependencies": {
"@apollo/react-testing": "^3.1.4",
"@babel/cli": "^7.5.5",
"@babel/core": "^7.7.7",
"@babel/plugin-proposal-class-properties": "^7.5.0",
@ -99,6 +100,7 @@
"@pollyjs/core": "^5.0.0",
"@pollyjs/persister-fs": "^5.0.0",
"@release-it/bumper": "^2.0.0",
"@saleor/app-bridge": "^0.1.5",
"@sentry/webpack-plugin": "^1.14.0",
"@storybook/addon-storyshots": "^5.2.8",
"@storybook/react": "^5.1.9",

View file

@ -222,6 +222,7 @@ type App implements Node & ObjectWithMetadata {
appUrl: String
version: String
accessToken: String
extensions: [AppExtension!]!
}
type AppActivate {
@ -289,6 +290,49 @@ enum AppErrorCode {
OUT_OF_SCOPE_PERMISSION
}
type AppExtension implements Node {
id: ID!
app: App!
label: String!
url: String!
view: AppExtensionViewEnum!
type: AppExtensionTypeEnum!
target: AppExtensionTargetEnum!
permissions: [Permission!]!
accessToken: String
}
type AppExtensionCountableConnection {
pageInfo: PageInfo!
edges: [AppExtensionCountableEdge!]!
totalCount: Int
}
type AppExtensionCountableEdge {
node: AppExtension!
cursor: String!
}
input AppExtensionFilterInput {
view: AppExtensionViewEnum
type: AppExtensionTypeEnum
target: AppExtensionTargetEnum
}
enum AppExtensionTargetEnum {
MORE_ACTIONS
CREATE
}
enum AppExtensionTypeEnum {
OVERVIEW
DETAILS
}
enum AppExtensionViewEnum {
PRODUCT
}
type AppFetchManifest {
manifest: Manifest
appErrors: [AppError!]! @deprecated(reason: "Use errors field instead. This field will be removed in Saleor 4.0.")
@ -329,6 +373,15 @@ type AppInstallation implements Node & Job {
message: String
}
type AppManifestExtension {
permissions: [Permission!]!
label: String!
url: String!
view: AppExtensionViewEnum!
type: AppExtensionTypeEnum!
target: AppExtensionTargetEnum!
}
type AppRetryInstall {
appErrors: [AppError!]! @deprecated(reason: "Use errors field instead. This field will be removed in Saleor 4.0.")
errors: [AppError!]!
@ -3243,6 +3296,7 @@ type Manifest {
dataPrivacyUrl: String
homepageUrl: String
supportUrl: String
extensions: [AppManifestExtension!]!
}
type Margin {
@ -3633,7 +3687,7 @@ type Mutation {
draftOrderCreate(input: DraftOrderCreateInput!): DraftOrderCreate
draftOrderDelete(id: ID!): DraftOrderDelete
draftOrderBulkDelete(ids: [ID]!): DraftOrderBulkDelete
draftOrderLinesBulkDelete(ids: [ID]!): DraftOrderLinesBulkDelete
draftOrderLinesBulkDelete(ids: [ID]!): DraftOrderLinesBulkDelete @deprecated(reason: "DEPRECATED: Will be removed in Saleor 4.0.")
draftOrderUpdate(id: ID!, input: DraftOrderInput!): DraftOrderUpdate
orderAddNote(order: ID!, input: OrderAddNoteInput!): OrderAddNote
orderCancel(id: ID!): OrderCancel
@ -3655,7 +3709,7 @@ type Mutation {
orderMarkAsPaid(id: ID!, transactionReference: String): OrderMarkAsPaid
orderRefund(amount: PositiveDecimal!, id: ID!): OrderRefund
orderUpdate(id: ID!, input: OrderUpdateInput!): OrderUpdate
orderUpdateShipping(order: ID!, input: OrderUpdateShippingInput): OrderUpdateShipping
orderUpdateShipping(order: ID!, input: OrderUpdateShippingInput!): OrderUpdateShipping
orderVoid(id: ID!): OrderVoid
orderBulkCancel(ids: [ID]!): OrderBulkCancel
deleteMetadata(id: ID!, keys: [String!]!): DeleteMetadata
@ -5626,6 +5680,8 @@ type Query {
appsInstallations: [AppInstallation!]!
apps(filter: AppFilterInput, sortBy: AppSortingInput, before: String, after: String, first: Int, last: Int): AppCountableConnection
app(id: ID): App
appExtensions(filter: AppExtensionFilterInput, before: String, after: String, first: Int, last: Int): AppExtensionCountableConnection
appExtension(id: ID!): AppExtension
addressValidationRules(countryCode: CountryCode!, countryArea: String, city: String, cityArea: String): AddressValidationData
address(id: ID!): Address
customers(filter: CustomerFilterInput, sortBy: UserSortingInput, before: String, after: String, first: Int, last: Int): UserCountableConnection

14
src/apps/apolloMocks.ts Normal file
View file

@ -0,0 +1,14 @@
import { MockedResponse } from "@apollo/react-testing";
import { extensionList } from "./queries";
export const mocks: MockedResponse[] = [
{
request: {
query: extensionList
},
result: {
data: []
}
}
];

View file

@ -11,8 +11,8 @@ import React from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { App_app } from "../../types/App";
import { AppFrame } from "../AppFrame";
import { useStyles } from "./styles";
import useAppConfigLoader from "./useAppConfigLoader";
import useSettingsBreadcrumbs from "./useSettingsBreadcrumbs";
export interface AppDetailsSettingsPageProps {
@ -34,10 +34,6 @@ export const AppDetailsSettingsPage: React.FC<AppDetailsSettingsPageProps> = ({
const classes = useStyles({});
const [breadcrumbs, onBreadcrumbClick] = useSettingsBreadcrumbs();
const { sendThemeToExtension } = useTheme();
const frameContainer = useAppConfigLoader(data, backendHost, {
onError,
onLoad: sendThemeToExtension
});
return (
<Container>
@ -104,7 +100,17 @@ export const AppDetailsSettingsPage: React.FC<AppDetailsSettingsPageProps> = ({
<Hr />
<CardSpacer />
<div ref={frameContainer} className={classes.iframeContainer} />
<div className={classes.iframeContainer}>
{data && (
<AppFrame
src={data.configurationUrl}
backendHost={backendHost}
appToken={data.accessToken}
onLoad={sendThemeToExtension}
onError={onError}
/>
)}
</div>
<CardSpacer />
</Container>
);

View file

@ -1,75 +0,0 @@
import { AppFragment } from "@saleor/fragments/types/AppFragment";
import { useEffect, useRef } from "react";
import urlJoin from "url-join";
export type UseAppConfigLoaderCallbacks = Record<
"onLoad" | "onError",
() => void
>;
function fixRelativeScriptSrc(origin: string) {
return (node: HTMLScriptElement) => {
// Using node.getAttribute beacuse node.src returns absolute path
const src = node.getAttribute("src");
if (src?.startsWith("/")) {
node.src = urlJoin(origin, src);
}
};
}
async function fetchAndSetContent(
frameContainer: HTMLDivElement,
data: AppFragment,
backendHostname: string,
{ onError, onLoad }: UseAppConfigLoaderCallbacks
) {
if (!frameContainer?.innerHTML && data?.configurationUrl) {
try {
const response = await fetch(data?.configurationUrl, {
headers: {
"x-saleor-domain": backendHostname,
"x-saleor-token": data.accessToken
},
method: "GET"
});
const url = new URL(response.url);
const text = await response.text();
const content = new DOMParser().parseFromString(text, "text/html");
const frame = document.createElement("iframe");
frame.src = "about:blank";
frame.id = "extension-app";
frameContainer.innerHTML = "";
frameContainer.appendChild(frame);
const frameContent = frame.contentWindow.document;
const documentElement = content.documentElement;
const scriptNodes = documentElement.querySelectorAll("script");
scriptNodes.forEach(fixRelativeScriptSrc(url.origin));
frameContent.write(content.documentElement.innerHTML);
frameContent.close();
frame.contentWindow.onload = onLoad;
} catch (error) {
console.error(error);
onError();
}
}
}
function useAppConfigLoader(
data: AppFragment,
backendHost: string,
callbacks: UseAppConfigLoaderCallbacks
) {
const frameContainer = useRef<HTMLDivElement>(null);
useEffect(() => {
fetchAndSetContent(frameContainer.current, data, backendHost, callbacks);
}, [data]);
return frameContainer;
}
export default useAppConfigLoader;

View file

@ -0,0 +1,36 @@
import {
Dialog,
DialogContent,
DialogProps,
DialogTitle,
IconButton,
Typography
} from "@material-ui/core";
import CloseIcon from "@material-ui/icons/Close";
import React from "react";
import { useStyles } from "./styles";
interface AppDialogProps extends DialogProps {
onClose: () => void;
}
export const AppDialog: React.FC<AppDialogProps> = ({ children, ...props }) => {
const classes = useStyles();
return (
<Dialog aria-labelledby="extension app dialog" {...props}>
<DialogTitle disableTypography className={classes.header}>
<Typography variant="h6" component="h2">
{props.title}
</Typography>
<IconButton color="inherit" onClick={props.onClose} aria-label="close">
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent className={classes.content}>{children}</DialogContent>
</Dialog>
);
};
export default AppDialog;

View file

@ -0,0 +1 @@
export * from "./AppDialog";

View file

@ -0,0 +1,19 @@
import { makeStyles } from "@saleor/macaw-ui";
export const useStyles = makeStyles(
() => ({
header: {
display: "flex",
justifyContent: "space-between",
alignItems: "center"
},
content: {
margin: 0,
padding: 0,
overflow: "hidden",
width: 600,
height: 600
}
}),
{ name: "AppDialog" }
);

View file

@ -0,0 +1,55 @@
import { useTheme } from "@saleor/macaw-ui";
import React from "react";
import urlJoin from "url-join";
import { useStyles } from "./styles";
import { useAppActions } from "./useAppActions";
interface Props {
src: string;
appToken: string;
backendHost: string;
onLoad?(): void;
onError?(): void;
}
const getOrigin = (url: string) => new URL(url).origin;
export const AppFrame: React.FC<Props> = ({
src,
appToken,
backendHost,
onLoad,
onError
}) => {
const frameRef = React.useRef<HTMLIFrameElement>();
const { sendThemeToExtension } = useTheme();
const classes = useStyles();
const appOrigin = getOrigin(src);
const { postToExtension } = useAppActions(frameRef, appOrigin);
const handleLoad = () => {
postToExtension({
type: "handshake",
payload: {
token: appToken,
version: 1
}
});
sendThemeToExtension();
if (onLoad) {
onLoad();
}
};
return (
<iframe
ref={frameRef}
src={urlJoin(src, `?domain=${backendHost}`)}
onError={onError}
onLoad={handleLoad}
className={classes.iframe}
/>
);
};

View file

@ -0,0 +1 @@
export * from "./AppFrame";

View file

@ -0,0 +1,12 @@
import { makeStyles } from "@saleor/macaw-ui";
export const useStyles = makeStyles(
() => ({
iframe: {
width: "100%",
height: "100%",
border: "none"
}
}),
{ name: "AppFrame" }
);

View file

@ -0,0 +1,74 @@
import { Actions, DispatchResponseEvent, Events } from "@saleor/app-bridge";
import useNavigator from "@saleor/hooks/useNavigator";
import React from "react";
import { useExternalApp } from "../ExternalAppContext";
const sendResponseStatus = (
actionId: string,
ok: boolean
): DispatchResponseEvent => ({
type: "response",
payload: {
actionId,
ok
}
});
export const useAppActions = (
frameEl: React.MutableRefObject<HTMLIFrameElement>,
appOrigin: string
) => {
const navigate = useNavigator();
const { closeApp } = useExternalApp();
const actionReducer = (
action: Actions | undefined
): DispatchResponseEvent => {
switch (action?.type) {
case "redirect": {
const { to, newContext, actionId } = action.payload;
if (newContext) {
window.open(to);
} else if (to.startsWith("/")) {
navigate(to);
closeApp();
} else {
window.location.href = to;
}
return sendResponseStatus(actionId, true);
}
default: {
return sendResponseStatus(action?.payload?.actionId, false);
}
}
};
const postToExtension = (event: Events) => {
if (frameEl.current) {
frameEl.current.contentWindow.postMessage(event, appOrigin);
}
};
React.useEffect(() => {
const handler = (event: MessageEvent<Actions>) => {
if (event.origin === appOrigin) {
const response = actionReducer(event.data);
postToExtension(response);
}
};
window.addEventListener("message", handler);
return () => {
window.removeEventListener("message", handler);
};
}, []);
return {
postToExtension
};
};

View file

@ -0,0 +1,56 @@
import React from "react";
import { AppDialog } from "../AppDialog";
import { AppFrame } from "../AppFrame";
export interface AppData {
appToken: string;
src: string;
backendUrl: string;
label: string;
}
const ExternalAppContext = React.createContext<{
open: boolean;
appData: AppData | undefined;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
setAppData: React.Dispatch<React.SetStateAction<AppData | undefined>>;
}>(undefined);
export const ExternalAppProvider: React.FC = ({ children }) => {
const [open, setOpen] = React.useState(false);
const [appData, setAppData] = React.useState<AppData | undefined>();
const handleClose = () => {
setOpen(false);
setAppData(undefined);
};
return (
<ExternalAppContext.Provider value={{ open, appData, setOpen, setAppData }}>
{children}
<AppDialog open={open} onClose={handleClose} title={appData?.label}>
{open && appData && (
<AppFrame
src={appData.src}
appToken={appData.appToken}
backendHost={appData.backendUrl}
/>
)}
</AppDialog>
</ExternalAppContext.Provider>
);
};
export const useExternalApp = () => {
const { open, setOpen, setAppData } = React.useContext(ExternalAppContext);
const openApp = (appData: AppData) => {
setOpen(true);
setAppData(appData);
};
const closeApp = () => setOpen(false);
return { open, openApp, closeApp };
};

View file

@ -0,0 +1 @@
export * from "./ExternalAppContext";

View file

@ -6,6 +6,7 @@ import gql from "graphql-tag";
import { App, AppVariables } from "./types/App";
import { AppsInstallations } from "./types/AppsInstallations";
import { AppsList, AppsListVariables } from "./types/AppsList";
import { ExtensionList, ExtensionListVariables } from "./types/ExtensionList";
const appsList = gql`
query AppsList(
@ -72,6 +73,24 @@ const appDetails = gql`
}
`;
export const extensionList = gql`
query ExtensionList($filter: AppExtensionFilterInput!) {
appExtensions(filter: $filter, first: 20) {
edges {
node {
id
label
url
view
type
target
accessToken
}
}
}
}
`;
export const useAppsListQuery = makeQuery<AppsList, AppsListVariables>(
appsList
);
@ -81,3 +100,8 @@ export const useAppsInProgressListQuery = makeQuery<AppsInstallations, {}>(
);
export const useAppDetails = makeQuery<App, AppVariables>(appDetails);
export const useExtensionList = makeQuery<
ExtensionList,
ExtensionListVariables
>(extensionList);

View file

@ -0,0 +1,39 @@
/* tslint:disable */
/* eslint-disable */
// @generated
// This file was automatically generated and should not be edited.
import { AppExtensionFilterInput, AppExtensionViewEnum, AppExtensionTypeEnum, AppExtensionTargetEnum } from "./../../types/globalTypes";
// ====================================================
// GraphQL query operation: ExtensionList
// ====================================================
export interface ExtensionList_appExtensions_edges_node {
__typename: "AppExtension";
id: string;
label: string;
url: string;
view: AppExtensionViewEnum;
type: AppExtensionTypeEnum;
target: AppExtensionTargetEnum;
accessToken: string | null;
}
export interface ExtensionList_appExtensions_edges {
__typename: "AppExtensionCountableEdge";
node: ExtensionList_appExtensions_edges_node;
}
export interface ExtensionList_appExtensions {
__typename: "AppExtensionCountableConnection";
edges: ExtensionList_appExtensions_edges[];
}
export interface ExtensionList {
appExtensions: ExtensionList_appExtensions | null;
}
export interface ExtensionListVariables {
filter: AppExtensionFilterInput;
}

75
src/apps/useExtensions.ts Normal file
View file

@ -0,0 +1,75 @@
import {
AppExtensionTargetEnum,
AppExtensionTypeEnum,
AppExtensionViewEnum
} from "@saleor/types/globalTypes";
import { mapEdgesToItems } from "@saleor/utils/maps";
import { AppData, useExternalApp } from "./components/ExternalAppContext";
import { useExtensionList } from "./queries";
import { ExtensionList_appExtensions_edges_node } from "./types/ExtensionList";
interface Extension {
id: string;
accessToken: string;
label: string;
url: string;
open(): void;
}
type Target = "create" | "moreActions";
const filterAndMapToTarget = (
extensions: ExtensionList_appExtensions_edges_node[],
target: AppExtensionTargetEnum,
openApp: (appData: AppData) => void
): Extension[] =>
extensions
.filter(app => app.target === target)
.map(({ id, accessToken, url, label }) => ({
id,
accessToken,
url,
label,
open: () =>
openApp({ appToken: accessToken, backendUrl: url, src: url, label })
}));
export const mapToMenuItems = (extensions: Extension[]) =>
extensions.map(({ label, id, open }) => ({
label,
testId: `extension-${id}`,
onSelect: open
}));
export const useExtensions = (
view: AppExtensionViewEnum,
type: AppExtensionTypeEnum
): Record<Target, Extension[]> => {
const { openApp } = useExternalApp();
const { data } = useExtensionList({
fetchPolicy: "cache-first",
variables: {
filter: {
view,
type
}
}
});
const extensions = mapEdgesToItems(data?.appExtensions) || [];
const targetCreate = filterAndMapToTarget(
extensions,
AppExtensionTargetEnum.CREATE,
openApp
);
const targetMoreActions = filterAndMapToTarget(
extensions,
AppExtensionTargetEnum.MORE_ACTIONS,
openApp
);
return {
create: targetCreate,
moreActions: targetMoreActions
};
};

View file

@ -0,0 +1,116 @@
import {
Button,
ButtonGroup,
ButtonGroupProps,
ClickAwayListener,
Grow,
MenuItem,
MenuList,
Paper,
Popper
} from "@material-ui/core";
import { ArrowDropDown as ArrowDropDownIcon } from "@material-ui/icons";
import React from "react";
import { useStyles } from "./styles";
interface Option {
label: string;
disabled?: boolean;
onSelect(e: React.MouseEvent<HTMLLIElement, MouseEvent>): void;
}
export interface ButtonWithSelectProps
extends Omit<ButtonGroupProps, "onClick"> {
options: Option[];
onClick(e: React.MouseEvent<HTMLButtonElement, MouseEvent>): void;
}
export const ButtonWithSelect: React.FC<ButtonWithSelectProps> = ({
options,
children,
onClick,
...props
}) => {
const [open, setOpen] = React.useState(false);
const anchorRef = React.useRef<HTMLDivElement>(null);
const classes = useStyles();
const handleMenuItemClick = (
event: React.MouseEvent<HTMLLIElement, MouseEvent>,
onClick: (event: React.MouseEvent<HTMLLIElement, MouseEvent>) => void
) => {
onClick(event);
setOpen(false);
};
const handleToggle = () => {
setOpen(prevOpen => !prevOpen);
};
const handleClose = (event: React.MouseEvent<Document, MouseEvent>) => {
if (
anchorRef.current &&
anchorRef.current.contains(event.target as HTMLElement)
) {
return;
}
setOpen(false);
};
return (
<>
<ButtonGroup
variant="contained"
color="primary"
ref={anchorRef}
aria-label="button with select"
{...props}
>
<Button onClick={onClick}>{children}</Button>
{options.length > 0 && (
<Button
color="primary"
size="small"
aria-controls={open ? "button-with-select-menu" : undefined}
aria-expanded={open ? "true" : undefined}
aria-label="select different option"
aria-haspopup="menu"
onClick={handleToggle}
>
<ArrowDropDownIcon className={classes.buttonIcon} />
</Button>
)}
</ButtonGroup>
<Popper
open={open}
anchorEl={anchorRef.current}
transition
disablePortal
placement="bottom-end"
className={classes.popper}
>
{({ TransitionProps }) => (
<Grow {...TransitionProps}>
<Paper className={classes.paper}>
<ClickAwayListener onClickAway={handleClose}>
<MenuList id="button-with-select-menu">
{options.map((option, i) => (
<MenuItem
key={option.label + i}
disabled={option.disabled}
onClick={e => handleMenuItemClick(e, option.onSelect)}
>
{option.label}
</MenuItem>
))}
</MenuList>
</ClickAwayListener>
</Paper>
</Grow>
)}
</Popper>
</>
);
};

View file

@ -0,0 +1,2 @@
export * from "./ButtonWithSelect";
export { ButtonWithSelect as default } from "./ButtonWithSelect";

View file

@ -0,0 +1,22 @@
import { makeStyles } from "@saleor/macaw-ui";
const ITEM_HEIGHT = 48;
export const useStyles = makeStyles(
theme => ({
popper: {
marginTop: theme.spacing(1),
zIndex: 2
},
paper: {
maxHeight: ITEM_HEIGHT * 4.5,
overflowY: "scroll"
},
buttonIcon: {
margin: 0
}
}),
{
name: "ButtonWithSelect"
}
);

View file

@ -40,7 +40,8 @@ const useStyles = makeStyles(
},
paper: {
marginTop: theme.spacing(2),
maxHeight: ITEM_HEIGHT * 4.5
maxHeight: ITEM_HEIGHT * 4.5,
overflowY: "scroll"
}
}),
{ name: "CardMenu" }

View file

@ -11,6 +11,9 @@ const useStyles = makeStyles(
return {
editor: {
"& .codex-editor": {
zIndex: 2
},
"& .cdx-quote__text": {
minHeight: 24
},

View file

@ -1,6 +1,6 @@
import { handleQueryAuthError } from "@saleor/auth";
import { RequireAtLeastOne } from "@saleor/misc";
import { ApolloQueryResult } from "apollo-client";
import { ApolloQueryResult, WatchQueryFetchPolicy } from "apollo-client";
import { DocumentNode } from "graphql";
import { useEffect } from "react";
import { QueryResult, useQuery as useBaseQuery } from "react-apollo";
@ -46,6 +46,7 @@ export type UseQueryOpts<TVariables> = Partial<{
displayLoader: boolean;
skip: boolean;
variables: TVariables;
fetchPolicy: WatchQueryFetchPolicy;
}>;
type UseQueryHook<TData, TVariables> = (
opts: UseQueryOpts<Omit<TVariables, PrefixedPermissions>>
@ -57,7 +58,8 @@ function makeQuery<TData, TVariables>(
function useQuery({
displayLoader,
skip,
variables
variables,
fetchPolicy
}: UseQueryOpts<TVariables>): UseQueryResult<TData, TVariables> {
const notify = useNotifier();
const intl = useIntl();
@ -78,7 +80,7 @@ function makeQuery<TData, TVariables>(
useBatching: true
},
errorPolicy: "all",
fetchPolicy: "cache-and-network",
fetchPolicy: fetchPolicy || "cache-and-network",
onError: error =>
handleQueryAuthError(
error,

View file

@ -14,6 +14,7 @@ import { useIntl } from "react-intl";
import { BrowserRouter, Route, Switch } from "react-router-dom";
import AppsSection from "./apps";
import { ExternalAppProvider } from "./apps/components/ExternalAppContext";
import { appsSection } from "./apps/urls";
import AttributeSection from "./attributes";
import { attributeSection } from "./attributes/urls";
@ -120,7 +121,9 @@ const App: React.FC = () => (
<ShopProvider>
<AuthProvider>
<AppChannelProvider>
<Routes />
<ExternalAppProvider>
<Routes />
</ExternalAppProvider>
</AppChannelProvider>
</AuthProvider>
</ShopProvider>

View file

@ -1,4 +1,6 @@
import { Button, Card } from "@material-ui/core";
import { Card } from "@material-ui/core";
import { mapToMenuItems, useExtensions } from "@saleor/apps/useExtensions";
import { ButtonWithSelect } from "@saleor/components/ButtonWithSelect";
import CardMenu from "@saleor/components/CardMenu";
import ColumnPicker, {
ColumnPickerChoice
@ -22,6 +24,10 @@ import {
PageListProps,
SortPage
} from "@saleor/types";
import {
AppExtensionTypeEnum,
AppExtensionViewEnum
} from "@saleor/types/globalTypes";
import { hasLimits, isLimitReached } from "@saleor/utils/limits";
import React from "react";
import { FormattedMessage, useIntl } from "react-intl";
@ -129,6 +135,13 @@ export const ProductListPage: React.FC<ProductListPageProps> = props => {
];
const limitReached = isLimitReached(limits, "productVariants");
const { create, moreActions } = useExtensions(
AppExtensionViewEnum.PRODUCT,
AppExtensionTypeEnum.OVERVIEW
);
const extensionMenuItems = mapToMenuItems(moreActions);
const extensionCreateButtonItems = mapToMenuItems(create);
return (
<Container>
@ -158,7 +171,8 @@ export const ProductListPage: React.FC<ProductListPageProps> = props => {
}),
onSelect: onExport,
testId: "export"
}
},
...extensionMenuItems
]}
data-test="menu"
/>
@ -176,18 +190,17 @@ export const ProductListPage: React.FC<ProductListPageProps> = props => {
onFetchMore={onFetchMore}
onSave={handleSave}
/>
<Button
<ButtonWithSelect
options={extensionCreateButtonItems}
data-test="add-product"
disabled={limitReached}
onClick={onAdd}
color="primary"
variant="contained"
data-test="add-product"
>
<FormattedMessage
defaultMessage="Create Product"
description="button"
/>
</Button>
</ButtonWithSelect>
</PageHeader>
{limitReached && (
<LimitReachedAlert

View file

@ -1,4 +1,5 @@
import { OutputData } from "@editorjs/editorjs";
import { mapToMenuItems, useExtensions } from "@saleor/apps/useExtensions";
import {
getAttributeValuesFromReferences,
mergeAttributeValues
@ -6,6 +7,7 @@ import {
import { ChannelData } from "@saleor/channels/utils";
import AssignAttributeValueDialog from "@saleor/components/AssignAttributeValueDialog";
import Attributes, { AttributeInput } from "@saleor/components/Attributes";
import CardMenu from "@saleor/components/CardMenu";
import CardSpacer from "@saleor/components/CardSpacer";
import ChannelsAvailabilityCard from "@saleor/components/ChannelsAvailabilityCard";
import { ConfirmButtonTransitionState } from "@saleor/components/ConfirmButton";
@ -40,7 +42,11 @@ import {
ListActions,
ReorderAction
} from "@saleor/types";
import { PermissionEnum } from "@saleor/types/globalTypes";
import {
AppExtensionTypeEnum,
AppExtensionViewEnum,
PermissionEnum
} from "@saleor/types/globalTypes";
import React from "react";
import { useIntl } from "react-intl";
@ -243,6 +249,13 @@ export const ProductUpdatePage: React.FC<ProductUpdatePageProps> = ({
onCloseDialog();
};
const { moreActions } = useExtensions(
AppExtensionViewEnum.PRODUCT,
AppExtensionTypeEnum.DETAILS
);
const extensionMenuItems = mapToMenuItems(moreActions);
return (
<ProductUpdateForm
isSimpleProduct={isSimpleProduct}
@ -283,7 +296,11 @@ export const ProductUpdatePage: React.FC<ProductUpdatePageProps> = ({
<Backlink onClick={onBack}>
{intl.formatMessage(sectionNames.products)}
</Backlink>
<PageHeader title={header} />
<PageHeader title={header}>
{extensionMenuItems.length > 0 && (
<CardMenu menuItems={extensionMenuItems} data-test="menu" />
)}
</PageHeader>
<Grid>
<div>
<ProductDetailsForm

View file

@ -1,3 +1,4 @@
import { ExternalAppProvider } from "@saleor/apps/components/ExternalAppContext";
import { Locale, RawLocaleProvider } from "@saleor/components/Locale";
import { ThemeProvider } from "@saleor/macaw-ui";
import themeOverrides from "@saleor/themeOverrides";
@ -5,37 +6,42 @@ import React from "react";
import { IntlProvider } from "react-intl";
import { BrowserRouter } from "react-router-dom";
import { ApolloMockedProvider } from "../../testUtils/ApolloMockedProvider";
import { Provider as DateProvider } from "../components/Date/DateContext";
import MessageManagerProvider from "../components/messages";
import { TimezoneProvider } from "../components/Timezone";
import { APP_MOUNT_URI } from "../config";
export const Decorator = storyFn => (
<IntlProvider defaultLocale={Locale.EN} locale={Locale.EN}>
<RawLocaleProvider
value={{
locale: Locale.EN,
setLocale: () => undefined
}}
>
<DateProvider value={+new Date("2018-08-07T14:30:44+00:00")}>
<TimezoneProvider value="America/New_York">
<ThemeProvider overrides={themeOverrides}>
<BrowserRouter basename={APP_MOUNT_URI}>
<MessageManagerProvider>
<div
style={{
padding: 24
}}
>
{storyFn()}
</div>
</MessageManagerProvider>
</BrowserRouter>
</ThemeProvider>
</TimezoneProvider>
</DateProvider>
</RawLocaleProvider>
</IntlProvider>
<ApolloMockedProvider>
<IntlProvider defaultLocale={Locale.EN} locale={Locale.EN}>
<RawLocaleProvider
value={{
locale: Locale.EN,
setLocale: () => undefined
}}
>
<DateProvider value={+new Date("2018-08-07T14:30:44+00:00")}>
<TimezoneProvider value="America/New_York">
<ThemeProvider overrides={themeOverrides}>
<BrowserRouter basename={APP_MOUNT_URI}>
<ExternalAppProvider>
<MessageManagerProvider>
<div
style={{
padding: 24
}}
>
{storyFn()}
</div>
</MessageManagerProvider>
</ExternalAppProvider>
</BrowserRouter>
</ThemeProvider>
</TimezoneProvider>
</DateProvider>
</RawLocaleProvider>
</IntlProvider>
</ApolloMockedProvider>
);
export default Decorator;

View file

@ -23485,7 +23485,12 @@ exports[`Storyshots Views / Apps / App details settings default 1`] = `
/>
<div
class="AppDetailsSettingsPage-iframeContainer-id"
/>
>
<iframe
class="AppFrame-iframe-id"
src="htpp://localhost:8888/configuration?domain=host"
/>
</div>
<div
class="CardSpacer-spacer-id"
/>
@ -213135,18 +213140,24 @@ exports[`Storyshots Views / Products / Product list default 1`] = `
</span>
</button>
</div>
<button
class="MuiButtonBase-root-id MuiButton-root-id MuiButton-contained-id MuiButton-containedPrimary-id"
<div
aria-label="button with select"
class="MuiButtonGroup-root-id MuiButtonGroup-contained-id"
data-test="add-product"
tabindex="0"
type="button"
role="group"
>
<span
class="MuiButton-label-id"
<button
class="MuiButtonBase-root-id MuiButton-root-id MuiButton-contained-id MuiButtonGroup-grouped-id MuiButtonGroup-groupedHorizontal-id MuiButtonGroup-groupedContained-id MuiButtonGroup-groupedContainedHorizontal-id MuiButtonGroup-groupedContainedPrimary-id MuiButton-containedPrimary-id"
tabindex="0"
type="button"
>
Create Product
</span>
</button>
<span
class="MuiButton-label-id"
>
Create Product
</span>
</button>
</div>
</div>
</div>
</div>
@ -215638,19 +215649,25 @@ exports[`Storyshots Views / Products / Product list limits reached 1`] = `
</span>
</button>
</div>
<button
class="MuiButtonBase-root-id MuiButton-root-id MuiButton-contained-id MuiButton-containedPrimary-id MuiButton-disabled-id MuiButtonBase-disabled-id"
<div
aria-label="button with select"
class="MuiButtonGroup-root-id MuiButtonGroup-contained-id"
data-test="add-product"
disabled=""
tabindex="-1"
type="button"
role="group"
>
<span
class="MuiButton-label-id"
<button
class="MuiButtonBase-root-id MuiButton-root-id MuiButton-contained-id MuiButtonGroup-grouped-id MuiButtonGroup-groupedHorizontal-id MuiButtonGroup-groupedContained-id MuiButtonGroup-groupedContainedHorizontal-id MuiButtonGroup-groupedContainedPrimary-id MuiButtonGroup-disabled-id MuiButton-containedPrimary-id MuiButton-disabled-id MuiButtonBase-disabled-id"
disabled=""
tabindex="-1"
type="button"
>
Create Product
</span>
</button>
<span
class="MuiButton-label-id"
>
Create Product
</span>
</button>
</div>
</div>
</div>
</div>
@ -218191,18 +218208,24 @@ exports[`Storyshots Views / Products / Product list loading 1`] = `
</span>
</button>
</div>
<button
class="MuiButtonBase-root-id MuiButton-root-id MuiButton-contained-id MuiButton-containedPrimary-id"
<div
aria-label="button with select"
class="MuiButtonGroup-root-id MuiButtonGroup-contained-id"
data-test="add-product"
tabindex="0"
type="button"
role="group"
>
<span
class="MuiButton-label-id"
<button
class="MuiButtonBase-root-id MuiButton-root-id MuiButton-contained-id MuiButtonGroup-grouped-id MuiButtonGroup-groupedHorizontal-id MuiButtonGroup-groupedContained-id MuiButtonGroup-groupedContainedHorizontal-id MuiButtonGroup-groupedContainedPrimary-id MuiButton-containedPrimary-id"
tabindex="0"
type="button"
>
Create Product
</span>
</button>
<span
class="MuiButton-label-id"
>
Create Product
</span>
</button>
</div>
</div>
</div>
</div>
@ -218750,18 +218773,24 @@ exports[`Storyshots Views / Products / Product list no channels 1`] = `
</span>
</button>
</div>
<button
class="MuiButtonBase-root-id MuiButton-root-id MuiButton-contained-id MuiButton-containedPrimary-id"
<div
aria-label="button with select"
class="MuiButtonGroup-root-id MuiButtonGroup-contained-id"
data-test="add-product"
tabindex="0"
type="button"
role="group"
>
<span
class="MuiButton-label-id"
<button
class="MuiButtonBase-root-id MuiButton-root-id MuiButton-contained-id MuiButtonGroup-grouped-id MuiButtonGroup-groupedHorizontal-id MuiButtonGroup-groupedContained-id MuiButtonGroup-groupedContainedHorizontal-id MuiButtonGroup-groupedContainedPrimary-id MuiButton-containedPrimary-id"
tabindex="0"
type="button"
>
Create Product
</span>
</button>
<span
class="MuiButton-label-id"
>
Create Product
</span>
</button>
</div>
</div>
</div>
</div>
@ -220953,18 +220982,24 @@ exports[`Storyshots Views / Products / Product list no data 1`] = `
</span>
</button>
</div>
<button
class="MuiButtonBase-root-id MuiButton-root-id MuiButton-contained-id MuiButton-containedPrimary-id"
<div
aria-label="button with select"
class="MuiButtonGroup-root-id MuiButtonGroup-contained-id"
data-test="add-product"
tabindex="0"
type="button"
role="group"
>
<span
class="MuiButton-label-id"
<button
class="MuiButtonBase-root-id MuiButton-root-id MuiButton-contained-id MuiButtonGroup-grouped-id MuiButtonGroup-groupedHorizontal-id MuiButtonGroup-groupedContained-id MuiButtonGroup-groupedContainedHorizontal-id MuiButtonGroup-groupedContainedPrimary-id MuiButton-containedPrimary-id"
tabindex="0"
type="button"
>
Create Product
</span>
</button>
<span
class="MuiButton-label-id"
>
Create Product
</span>
</button>
</div>
</div>
</div>
</div>
@ -221372,18 +221407,24 @@ exports[`Storyshots Views / Products / Product list no limits 1`] = `
</span>
</button>
</div>
<button
class="MuiButtonBase-root-id MuiButton-root-id MuiButton-contained-id MuiButton-containedPrimary-id"
<div
aria-label="button with select"
class="MuiButtonGroup-root-id MuiButtonGroup-contained-id"
data-test="add-product"
tabindex="0"
type="button"
role="group"
>
<span
class="MuiButton-label-id"
<button
class="MuiButtonBase-root-id MuiButton-root-id MuiButton-contained-id MuiButtonGroup-grouped-id MuiButtonGroup-groupedHorizontal-id MuiButtonGroup-groupedContained-id MuiButtonGroup-groupedContainedHorizontal-id MuiButtonGroup-groupedContainedPrimary-id MuiButton-containedPrimary-id"
tabindex="0"
type="button"
>
Create Product
</span>
</button>
<span
class="MuiButton-label-id"
>
Create Product
</span>
</button>
</div>
</div>
</div>
</div>
@ -223875,18 +223916,24 @@ exports[`Storyshots Views / Products / Product list with data 1`] = `
</span>
</button>
</div>
<button
class="MuiButtonBase-root-id MuiButton-root-id MuiButton-contained-id MuiButton-containedPrimary-id"
<div
aria-label="button with select"
class="MuiButtonGroup-root-id MuiButtonGroup-contained-id"
data-test="add-product"
tabindex="0"
type="button"
role="group"
>
<span
class="MuiButton-label-id"
<button
class="MuiButtonBase-root-id MuiButton-root-id MuiButton-contained-id MuiButtonGroup-grouped-id MuiButtonGroup-groupedHorizontal-id MuiButtonGroup-groupedContained-id MuiButtonGroup-groupedContainedHorizontal-id MuiButtonGroup-groupedContainedPrimary-id MuiButton-containedPrimary-id"
tabindex="0"
type="button"
>
Create Product
</span>
</button>
<span
class="MuiButton-label-id"
>
Create Product
</span>
</button>
</div>
</div>
</div>
</div>

View file

@ -63,6 +63,20 @@ export enum AppErrorCode {
UNIQUE = "UNIQUE",
}
export enum AppExtensionTargetEnum {
CREATE = "CREATE",
MORE_ACTIONS = "MORE_ACTIONS",
}
export enum AppExtensionTypeEnum {
DETAILS = "DETAILS",
OVERVIEW = "OVERVIEW",
}
export enum AppExtensionViewEnum {
PRODUCT = "PRODUCT",
}
export enum AppSortField {
CREATION_DATE = "CREATION_DATE",
NAME = "NAME",
@ -1833,6 +1847,12 @@ export interface AddressInput {
phone?: string | null;
}
export interface AppExtensionFilterInput {
view?: AppExtensionViewEnum | null;
type?: AppExtensionTypeEnum | null;
target?: AppExtensionTargetEnum | null;
}
export interface AppFilterInput {
search?: string | null;
isActive?: boolean | null;

View file

@ -0,0 +1,10 @@
import { MockedProvider, MockedResponse } from "@apollo/react-testing";
// mocks
import { mocks as appMocks } from "@saleor/apps/apolloMocks";
import React from "react";
const mocks: MockedResponse[] = [...appMocks];
export const ApolloMockedProvider = ({ children }) => (
<MockedProvider mocks={mocks}>{children}</MockedProvider>
);

View file

@ -1,3 +1,4 @@
import { ExternalAppProvider } from "@saleor/apps/components/ExternalAppContext";
import { Provider as DateProvider } from "@saleor/components/Date/DateContext";
import { Locale, RawLocaleProvider } from "@saleor/components/Locale";
import { TimezoneProvider } from "@saleor/components/Timezone";
@ -5,21 +6,27 @@ import { ThemeProvider } from "@saleor/macaw-ui";
import React from "react";
import { IntlProvider } from "react-intl";
import { ApolloMockedProvider } from "./ApolloMockedProvider";
const Wrapper: React.FC = ({ children }) => (
<IntlProvider defaultLocale={Locale.EN} locale={Locale.EN}>
<RawLocaleProvider
value={{
locale: Locale.EN,
setLocale: () => undefined
}}
>
<DateProvider value={+new Date("2018-08-07T14:30:44+00:00")}>
<TimezoneProvider value="America/New_York">
<ThemeProvider>{children}</ThemeProvider>
</TimezoneProvider>
</DateProvider>
</RawLocaleProvider>
</IntlProvider>
<ApolloMockedProvider>
<IntlProvider defaultLocale={Locale.EN} locale={Locale.EN}>
<RawLocaleProvider
value={{
locale: Locale.EN,
setLocale: () => undefined
}}
>
<DateProvider value={+new Date("2018-08-07T14:30:44+00:00")}>
<TimezoneProvider value="America/New_York">
<ThemeProvider>
<ExternalAppProvider>{children}</ExternalAppProvider>
</ThemeProvider>
</TimezoneProvider>
</DateProvider>
</RawLocaleProvider>
</IntlProvider>
</ApolloMockedProvider>
);
export default Wrapper;