From ab4c897b2ac57ed9332767eaae11d7e471ecbe9c Mon Sep 17 00:00:00 2001 From: Jakub Majorek Date: Fri, 20 Aug 2021 15:58:53 +0200 Subject: [PATCH] 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 --- package-lock.json | 33 +++- package.json | 4 +- schema.graphql | 60 +++++- src/apps/apolloMocks.ts | 14 ++ .../AppDetailsSettingsPage.tsx | 18 +- .../useAppConfigLoader.ts | 75 -------- src/apps/components/AppDialog/AppDialog.tsx | 36 ++++ src/apps/components/AppDialog/index.ts | 1 + src/apps/components/AppDialog/styles.ts | 19 ++ src/apps/components/AppFrame/AppFrame.tsx | 55 ++++++ src/apps/components/AppFrame/index.ts | 1 + src/apps/components/AppFrame/styles.ts | 12 ++ src/apps/components/AppFrame/useAppActions.ts | 74 ++++++++ .../ExternalAppContext/ExternalAppContext.tsx | 56 ++++++ .../components/ExternalAppContext/index.ts | 1 + src/apps/queries.ts | 24 +++ src/apps/types/ExtensionList.ts | 39 ++++ src/apps/useExtensions.ts | 75 ++++++++ .../ButtonWithSelect/ButtonWithSelect.tsx | 116 ++++++++++++ src/components/ButtonWithSelect/index.ts | 2 + src/components/ButtonWithSelect/styles.ts | 22 +++ src/components/CardMenu/CardMenu.tsx | 3 +- src/components/RichTextEditor/styles.ts | 3 + src/hooks/makeQuery.ts | 8 +- src/index.tsx | 5 +- .../ProductListPage/ProductListPage.tsx | 27 ++- .../ProductUpdatePage/ProductUpdatePage.tsx | 21 ++- src/storybook/Decorator.tsx | 58 +++--- .../__snapshots__/Stories.test.ts.snap | 177 +++++++++++------- src/types/globalTypes.ts | 20 ++ testUtils/ApolloMockedProvider.tsx | 10 + testUtils/wrapper.tsx | 35 ++-- 32 files changed, 899 insertions(+), 205 deletions(-) create mode 100644 src/apps/apolloMocks.ts delete mode 100644 src/apps/components/AppDetailsSettingsPage/useAppConfigLoader.ts create mode 100644 src/apps/components/AppDialog/AppDialog.tsx create mode 100644 src/apps/components/AppDialog/index.ts create mode 100644 src/apps/components/AppDialog/styles.ts create mode 100644 src/apps/components/AppFrame/AppFrame.tsx create mode 100644 src/apps/components/AppFrame/index.ts create mode 100644 src/apps/components/AppFrame/styles.ts create mode 100644 src/apps/components/AppFrame/useAppActions.ts create mode 100644 src/apps/components/ExternalAppContext/ExternalAppContext.tsx create mode 100644 src/apps/components/ExternalAppContext/index.ts create mode 100644 src/apps/types/ExtensionList.ts create mode 100644 src/apps/useExtensions.ts create mode 100644 src/components/ButtonWithSelect/ButtonWithSelect.tsx create mode 100644 src/components/ButtonWithSelect/index.ts create mode 100644 src/components/ButtonWithSelect/styles.ts create mode 100644 testUtils/ApolloMockedProvider.tsx diff --git a/package-lock.json b/package-lock.json index e24dcdca5..7a464e93b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 2ee73c3f6..94073b381 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/schema.graphql b/schema.graphql index 013e8b9d3..7a5a41d0f 100644 --- a/schema.graphql +++ b/schema.graphql @@ -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 diff --git a/src/apps/apolloMocks.ts b/src/apps/apolloMocks.ts new file mode 100644 index 000000000..7b8a5a01d --- /dev/null +++ b/src/apps/apolloMocks.ts @@ -0,0 +1,14 @@ +import { MockedResponse } from "@apollo/react-testing"; + +import { extensionList } from "./queries"; + +export const mocks: MockedResponse[] = [ + { + request: { + query: extensionList + }, + result: { + data: [] + } + } +]; diff --git a/src/apps/components/AppDetailsSettingsPage/AppDetailsSettingsPage.tsx b/src/apps/components/AppDetailsSettingsPage/AppDetailsSettingsPage.tsx index 458ca11af..a9efff6e0 100644 --- a/src/apps/components/AppDetailsSettingsPage/AppDetailsSettingsPage.tsx +++ b/src/apps/components/AppDetailsSettingsPage/AppDetailsSettingsPage.tsx @@ -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 = ({ const classes = useStyles({}); const [breadcrumbs, onBreadcrumbClick] = useSettingsBreadcrumbs(); const { sendThemeToExtension } = useTheme(); - const frameContainer = useAppConfigLoader(data, backendHost, { - onError, - onLoad: sendThemeToExtension - }); return ( @@ -104,7 +100,17 @@ export const AppDetailsSettingsPage: React.FC = ({
-
+
+ {data && ( + + )} +
); diff --git a/src/apps/components/AppDetailsSettingsPage/useAppConfigLoader.ts b/src/apps/components/AppDetailsSettingsPage/useAppConfigLoader.ts deleted file mode 100644 index c73221d7c..000000000 --- a/src/apps/components/AppDetailsSettingsPage/useAppConfigLoader.ts +++ /dev/null @@ -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(null); - - useEffect(() => { - fetchAndSetContent(frameContainer.current, data, backendHost, callbacks); - }, [data]); - - return frameContainer; -} - -export default useAppConfigLoader; diff --git a/src/apps/components/AppDialog/AppDialog.tsx b/src/apps/components/AppDialog/AppDialog.tsx new file mode 100644 index 000000000..9e0325514 --- /dev/null +++ b/src/apps/components/AppDialog/AppDialog.tsx @@ -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 = ({ children, ...props }) => { + const classes = useStyles(); + + return ( + + + + {props.title} + + + + + + {children} + + ); +}; + +export default AppDialog; diff --git a/src/apps/components/AppDialog/index.ts b/src/apps/components/AppDialog/index.ts new file mode 100644 index 000000000..b23afc416 --- /dev/null +++ b/src/apps/components/AppDialog/index.ts @@ -0,0 +1 @@ +export * from "./AppDialog"; diff --git a/src/apps/components/AppDialog/styles.ts b/src/apps/components/AppDialog/styles.ts new file mode 100644 index 000000000..c66b0f9a8 --- /dev/null +++ b/src/apps/components/AppDialog/styles.ts @@ -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" } +); diff --git a/src/apps/components/AppFrame/AppFrame.tsx b/src/apps/components/AppFrame/AppFrame.tsx new file mode 100644 index 000000000..505f7b6dc --- /dev/null +++ b/src/apps/components/AppFrame/AppFrame.tsx @@ -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 = ({ + src, + appToken, + backendHost, + onLoad, + onError +}) => { + const frameRef = React.useRef(); + 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 ( +