diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index e7706d389..bbc3462d7 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -25,7 +25,8 @@ greatly reduce the amount of work needed to review your work. --> Modify API_URI if you want test instance to use custom backend. CYPRESS_API_URI is optional, use when necessary. --> API_URI=https://automation-dashboard.staging.saleor.cloud/graphql/ -MARKETPLACE_URL=https://marketplace-gray.vercel.app/ +MARKETPLACE_URL=https://apps.saleor.io +SALEOR_APPS_ENDPOINT=https://apps.saleor.io/api/saleor-apps ### Do you want to run more stable tests? To run all tests, just select the stable checkbox. To speed up tests, increase the number of containers. Tests will be re-run only when the "run e2e" label is added. diff --git a/.github/workflows/deploy-cloud.yaml b/.github/workflows/deploy-cloud.yaml index 85cd4a4a1..3ff432f34 100644 --- a/.github/workflows/deploy-cloud.yaml +++ b/.github/workflows/deploy-cloud.yaml @@ -18,6 +18,7 @@ jobs: SENTRY_DSN: ${{ secrets.SENTRY_DSN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} MARKETPLACE_URL: "https://apps.saleor.io/" + SALEOR_APPS_ENDPOINT: "https://apps.saleor.io/api/saleor-apps" IS_CLOUD_INSTANCE: true steps: - uses: actions/checkout@v2 diff --git a/.github/workflows/deploy-demo-staging.yaml b/.github/workflows/deploy-demo-staging.yaml index 8d240a42c..58e8d6659 100644 --- a/.github/workflows/deploy-demo-staging.yaml +++ b/.github/workflows/deploy-demo-staging.yaml @@ -21,6 +21,7 @@ jobs: SENTRY_DSN: ${{ secrets.SENTRY_DSN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} MARKETPLACE_URL: "https://apps.saleor.io/" + SALEOR_APPS_ENDPOINT: "https://apps.saleor.io/api/saleor-apps" ENVIRONMENT: demo-staging DEMO_MODE: true steps: diff --git a/.github/workflows/deploy-demo.yaml b/.github/workflows/deploy-demo.yaml index aeaf09654..f39246bdf 100644 --- a/.github/workflows/deploy-demo.yaml +++ b/.github/workflows/deploy-demo.yaml @@ -16,6 +16,7 @@ jobs: SENTRY_DSN: ${{ secrets.SENTRY_DSN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} MARKETPLACE_URL: "https://apps.saleor.io/" + SALEOR_APPS_ENDPOINT: "https://apps.saleor.io/api/saleor-apps" ENVIRONMENT: demo DEMO_MODE: true steps: diff --git a/.github/workflows/deploy-master-staging.yaml b/.github/workflows/deploy-master-staging.yaml index 190b5e104..2b4cb8dce 100644 --- a/.github/workflows/deploy-master-staging.yaml +++ b/.github/workflows/deploy-master-staging.yaml @@ -18,6 +18,7 @@ jobs: SENTRY_DSN: ${{ secrets.SENTRY_DSN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} MARKETPLACE_URL: "https://apps.saleor.io/" + SALEOR_APPS_ENDPOINT: "https://apps.saleor.io/api/saleor-apps" ENVIRONMENT: saleor-master-staging IS_CLOUD_INSTANCE: true steps: diff --git a/.github/workflows/deploy-staging.yaml b/.github/workflows/deploy-staging.yaml index f3a104ff9..031dbbc14 100644 --- a/.github/workflows/deploy-staging.yaml +++ b/.github/workflows/deploy-staging.yaml @@ -23,6 +23,7 @@ jobs: SENTRY_DSN: ${{ secrets.SENTRY_DSN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} MARKETPLACE_URL: "https://apps.saleor.io/" + SALEOR_APPS_ENDPOINT: "https://apps.saleor.io/api/saleor-apps" VERSION: ${{ github.event.inputs.git_ref || github.ref_name }} IS_CLOUD_INSTANCE: true steps: diff --git a/Dockerfile b/Dockerfile index 45c2c3cec..f85aa0d96 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,11 +6,12 @@ COPY . . ARG APP_MOUNT_URI ARG API_URI ARG MARKETPLACE_URL +ARG SALEOR_APPS_ENDPOINT ARG STATIC_URL ENV API_URI ${API_URI:-http://localhost:8000/graphql/} ENV APP_MOUNT_URI ${APP_MOUNT_URI:-/dashboard/} ENV STATIC_URL ${STATIC_URL:-/dashboard/} -RUN STATIC_URL=${STATIC_URL} API_URI=${API_URI} MARKETPLACE_URL=${MARKETPLACE_URL} APP_MOUNT_URI=${APP_MOUNT_URI} npm run build +RUN STATIC_URL=${STATIC_URL} API_URI=${API_URI} MARKETPLACE_URL=${MARKETPLACE_URL} SALEOR_APPS_ENDPOINT=${SALEOR_APPS_ENDPOINT} APP_MOUNT_URI=${APP_MOUNT_URI} npm run build FROM nginx:stable WORKDIR /app diff --git a/Dockerfile.dev b/Dockerfile.dev index e2d65bc6c..75e136324 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -6,6 +6,7 @@ COPY . . ARG APP_MOUNT_URI ARG API_URI ARG MARKETPLACE_URL +ARG SALEOR_APPS_ENDPOINT ARG STATIC_URL ENV API_URI ${API_URI:-http://localhost:8000/graphql/} ENV APP_MOUNT_URI ${APP_MOUNT_URI:-/} diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index 490a2bc11..ee9eab24c 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -97,6 +97,9 @@ "context": "dialog header", "string": "Change Password" }, + "+niGip": { + "string": "Saleor Apps" + }, "+pLi+M": { "context": "issued by app label", "string": "Issued by app" @@ -2013,6 +2016,9 @@ "context": "change warehouse dialog warehouse list label", "string": "Warehouses A to Z" }, + "EqDdoh": { + "string": "Local apps are custom webhooks & token pairs that can be used to connect apps and access Saleor API. Read more." + }, "ErNH3D": { "string": "Define where this attribute should be used in Saleor system" }, @@ -2077,6 +2083,9 @@ "context": "dialog content", "string": "Select method you want to use to change address" }, + "FLtdaw": { + "string": "Saleor apps are hosted and maintained by Saleor Team. They are preinstalled for you and ready to use" + }, "FNAZoh": { "string": "Last login" }, @@ -2646,6 +2655,9 @@ "context": "currency code select", "string": "{code} - {countries}" }, + "J8frvS": { + "string": "3rd party apps" + }, "JDz5h8": { "context": "number of subcategories in category", "string": "Subcategories" @@ -3438,6 +3450,10 @@ "context": "attribute list", "string": "Attribute {number}" }, + "PbQJY5": { + "context": "section header", + "string": "Saleor Apps" + }, "PbqNhi": { "context": "order status", "string": "Partially fulfilled" @@ -3628,6 +3644,9 @@ "context": "number of collections", "string": "Collections ({quantity})" }, + "QdQ9z7": { + "string": "(TUNNEL - DEVELOPMENT)" + }, "Qe4XHv": { "context": "years after label", "string": "years after issue" @@ -4153,6 +4172,9 @@ "context": "attribute values", "string": "Value {number}" }, + "UxTSw7": { + "string": "Webhooks & Events" + }, "UxdBmI": { "context": "collection availability", "string": "Availability" @@ -7398,6 +7420,9 @@ "context": "gift card history message", "string": "Gift card tags were updated" }, + "vkY3W9": { + "string": "Third party apps are installed with App Manifests. They contain UI accessible from dashboard and can extend it. Read more here." + }, "vlLyvk": { "string": "{inputType} attributes cannot be used as variant selection attributes." }, diff --git a/src/apps/components/AppManifestTableDisplay/AppManifestTableDisplay.tsx b/src/apps/components/AppManifestTableDisplay/AppManifestTableDisplay.tsx new file mode 100644 index 000000000..7c21925cc --- /dev/null +++ b/src/apps/components/AppManifestTableDisplay/AppManifestTableDisplay.tsx @@ -0,0 +1,72 @@ +import { Typography } from "@material-ui/core"; +import { CopyIcon, makeStyles, Tooltip } from "@saleor/macaw-ui"; +import clsx from "clsx"; +import React, { useState } from "react"; + +const useStyles = makeStyles( + theme => ({ + "@keyframes pulse": { + from: { transform: "scale(1)" }, + to: { transform: "scale(1.2)" }, + }, + manifestText: { + color: theme.palette.text.secondary, + "&:hover svg": { + visibility: "visible", + }, + }, + copyIcon: { + marginRight: theme.spacing(1), + visibility: "hidden", + verticalAlign: "middle", + transition: "0.2s", + }, + copyIconColorful: { + color: theme.palette.primary.main, + animation: "$pulse 0.2s", + }, + }), + { name: "AppManifestTableDisplay" }, +); + +interface AppManifestTableDisplayProps { + manifestUrl: string; +} + +const getAppDomainFromManifest = (manifest: string) => new URL(manifest).host; + +export const AppManifestTableDisplay = ({ + manifestUrl, +}: AppManifestTableDisplayProps) => { + const styles = useStyles(); + const [copied, setCopied] = useState(false); + + return ( + + setCopied(false)} + className={styles.manifestText} + onClick={e => { + try { + e.stopPropagation(); + e.preventDefault(); + + navigator.clipboard.writeText(manifestUrl); + setCopied(true); + } catch (e) { + // Copy not supported, ignore + } + }} + > + {!!navigator.clipboard && ( + + )} + {getAppDomainFromManifest(manifestUrl)} + + + ); +}; diff --git a/src/apps/components/AppPageTabs/AppPageTabs.tsx b/src/apps/components/AppPageTabs/AppPageTabs.tsx new file mode 100644 index 000000000..ca31b57f8 --- /dev/null +++ b/src/apps/components/AppPageTabs/AppPageTabs.tsx @@ -0,0 +1,46 @@ +import { PageTab, PageTabs } from "@saleor/macaw-ui"; +import React, { ComponentProps } from "react"; +import { useIntl } from "react-intl"; + +export type AppPageTabValue = + | "THIRD_PARTY" + | "WEBHOOKS_AND_EVENTS" + | "SALEOR_APPS"; + +type AllProps = ComponentProps; +type AvailableProps = Omit & { + value: AppPageTabValue; + showSaleorApps: boolean; + onChange(newValue: AppPageTabValue): void; +}; + +export const AppPageTabs = ({ showSaleorApps, ...props }: AvailableProps) => { + const intl = useIntl(); + return ( + + + + {showSaleorApps && ( + + )} + + ); +}; diff --git a/src/apps/components/AppsListPage/AppsListPage.tsx b/src/apps/components/AppsListPage/AppsListPage.tsx index 7a7f1f36a..9a8524653 100644 --- a/src/apps/components/AppsListPage/AppsListPage.tsx +++ b/src/apps/components/AppsListPage/AppsListPage.tsx @@ -1,11 +1,17 @@ +import { + AppPageTabs, + AppPageTabValue, +} from "@saleor/apps/components/AppPageTabs/AppPageTabs"; +import { useSaleorApps } from "@saleor/apps/hooks/useSaleorApps"; import CardSpacer from "@saleor/components/CardSpacer"; import Container from "@saleor/components/Container"; import PageHeader from "@saleor/components/PageHeader"; import { AppsInstallationsQuery, AppsListQuery } from "@saleor/graphql"; import { sectionNames } from "@saleor/intl"; +import { makeStyles } from "@saleor/macaw-ui"; import { ListProps } from "@saleor/types"; -import React from "react"; -import { useIntl } from "react-intl"; +import React, { useEffect, useMemo, useState } from "react"; +import { FormattedMessage, useIntl } from "react-intl"; import AppsInProgress from "../AppsInProgress/AppsInProgress"; import CustomApps from "../CustomApps/CustomApps"; @@ -22,6 +28,17 @@ export interface AppsListPageProps extends ListProps { onAppInstallRetry: (id: string) => void; } +const useStyles = makeStyles( + theme => ({ + topTabs: { + marginBottom: theme.spacing(4), + }, + }), + { + name: "AppsListPageStyles", + }, +); + const AppsListPage: React.FC = ({ appsInProgressList, customAppsList, @@ -33,34 +50,140 @@ const AppsListPage: React.FC = ({ onAppInstallRetry, ...listProps }) => { + const { + fetchApps, + apps: fetchedSaleorApps, + saleorAppsEnabled, + } = useSaleorApps(); + + useEffect(() => { + if (saleorAppsEnabled) { + fetchApps(); + } + }, [saleorAppsEnabled, fetchApps]); + + const styles = useStyles(); const intl = useIntl(); + const [activeTab, setActiveTab] = useState("THIRD_PARTY"); const appsInProgress = appsInProgressList?.appsInstallations; + const thirdPartyApps = useMemo( + () => + installedAppsList?.filter( + app => + !(fetchedSaleorApps ?? []).find(fetchedApp => + app.node.manifestUrl.includes(fetchedApp.hostname), + ), + ), + [installedAppsList, fetchedSaleorApps], + ); + + const saleorApps = useMemo( + () => + fetchedSaleorApps + ?.map(app => + installedAppsList?.find(installedApp => + installedApp.node.manifestUrl.includes(app.hostname), + ), + ) + .filter(Boolean), + [installedAppsList, fetchedSaleorApps], + ); + + const renderContent = () => { + switch (activeTab) { + case "THIRD_PARTY": { + return ( + <> +

+ +

+ + + + + {!!appsInProgress?.length && ( + <> + + + + )} + + ); + } + case "WEBHOOKS_AND_EVENTS": { + return ( + <> +

+ +

+ + + ); + } + case "SALEOR_APPS": { + return ( + <> +

+ +

+ + + ); + } + } + }; + return ( - - - - {!!appsInProgress?.length && ( - <> - - - - )} + {renderContent()} ); }; diff --git a/src/apps/components/InstalledApps/InstalledApps.tsx b/src/apps/components/InstalledApps/InstalledApps.tsx index af066224c..17735c057 100644 --- a/src/apps/components/InstalledApps/InstalledApps.tsx +++ b/src/apps/components/InstalledApps/InstalledApps.tsx @@ -6,9 +6,11 @@ import { TableRow, Typography, } from "@material-ui/core"; +import { AppManifestTableDisplay } from "@saleor/apps/components/AppManifestTableDisplay/AppManifestTableDisplay"; import { InstallWithManifestFormButton } from "@saleor/apps/components/InstallWithManifestFormButton"; import { useAppListContext } from "@saleor/apps/context"; import { appUrl, createAppInstallUrl } from "@saleor/apps/urls"; +import { isAppInTunnel } from "@saleor/apps/utils"; import CardTitle from "@saleor/components/CardTitle"; import { IconButton } from "@saleor/components/IconButton"; import { TableButtonWrapper } from "@saleor/components/TableButtonWrapper/TableButtonWrapper"; @@ -18,9 +20,8 @@ import useNavigator from "@saleor/hooks/useNavigator"; import { DeleteIcon, ResponsiveTable } from "@saleor/macaw-ui"; import { renderCollection } from "@saleor/misc"; import { ListProps } from "@saleor/types"; -import clsx from "clsx"; import React, { useCallback } from "react"; -import { FormattedMessage, useIntl } from "react-intl"; +import { FormattedMessage } from "react-intl"; import { useStyles } from "../../styles"; import { AppPermissions } from "../AppPermissions/AppPermissions"; @@ -29,14 +30,17 @@ import AppsSkeleton from "../AppsSkeleton"; export interface InstalledAppsProps extends ListProps { appsList: AppsListQuery["apps"]["edges"]; onRemove: (id: string) => void; + displayQuickManifestButton?: boolean; + title: string; } const InstalledApps: React.FC = ({ appsList, onRemove, + title, + displayQuickManifestButton = false, ...props }) => { - const intl = useIntl(); const classes = useStyles(props); const { activateApp, deactivateApp } = useAppListContext(); const navigate = useNavigator(); @@ -59,15 +63,15 @@ const InstalledApps: React.FC = ({ return ( + displayQuickManifestButton ? ( + + ) : ( + undefined + ) } /> @@ -85,15 +89,22 @@ const InstalledApps: React.FC = ({ {app.node.name} + {app.node.manifestUrl && + isAppInTunnel(app.node.manifestUrl) ? ( + + + + ) : null} + {app.node.manifestUrl && ( - - {app.node.manifestUrl} - + )} { + const [apps, setApps] = useState(undefined); + + const fetchApps = useCallback(async () => { + if (!saleorAppsEnabled) { + return; + } + + return fetch(SALEOR_APPS_ENDPOINT) + .then(response => response.json()) + .then((data: SaleorApp[]) => { + if ( + !data.every( + item => + typeof item.hostname === "string" || + typeof item.name === "string", + ) + ) { + console.error( + 'Invalid Saleor Apps data received from Marketplace. Expected array of objects with "name" and "hostname" properties', + ); + return; + } + + setApps(data); + }); + }, []); + + return { + saleorAppsEnabled, + apps, + fetchApps, + }; +}; diff --git a/src/apps/styles.ts b/src/apps/styles.ts index 3de4fc0d3..99e91a923 100644 --- a/src/apps/styles.ts +++ b/src/apps/styles.ts @@ -53,7 +53,7 @@ export const useStyles = makeStyles( }, colName: { paddingLeft: 0, - width: theme.spacing(30), + minWidth: theme.spacing(30), }, colSpinner: { "& svg": { @@ -213,9 +213,6 @@ export const useStyles = makeStyles( marginRight: theme.spacing(1), }, }, - manifestUrl: { - marginRight: theme.spacing(1), - }, }), { name: "AppList" }, ); diff --git a/src/apps/utils.ts b/src/apps/utils.ts new file mode 100644 index 000000000..9ceb29d2a --- /dev/null +++ b/src/apps/utils.ts @@ -0,0 +1,4 @@ +const tunnelKeywords = [".ngrok.io", ".saleor.live"]; + +export const isAppInTunnel = (manifestUrl: string) => + Boolean(tunnelKeywords.find(keyword => manifestUrl.includes(keyword))); diff --git a/src/config.ts b/src/config.ts index 82f61f663..664f40851 100644 --- a/src/config.ts +++ b/src/config.ts @@ -9,6 +9,7 @@ export const API_URI = process.env.API_URI; export const SW_INTERVAL = parseInt(process.env.SW_INTERVAL, 10); export const IS_CLOUD_INSTANCE = process.env.IS_CLOUD_INSTANCE === "true"; export const MARKETPLACE_URL = process.env.MARKETPLACE_URL; +export const SALEOR_APPS_ENDPOINT = process.env.SALEOR_APPS_ENDPOINT; export const DEFAULT_INITIAL_SEARCH_DATA: SearchVariables = { after: null, diff --git a/src/storybook/__snapshots__/Stories.test.ts.snap b/src/storybook/__snapshots__/Stories.test.ts.snap index 0105ed6f8..f2585a71c 100644 --- a/src/storybook/__snapshots__/Stories.test.ts.snap +++ b/src/storybook/__snapshots__/Stories.test.ts.snap @@ -25005,6 +25005,53 @@ exports[`Storyshots Views / Apps / Apps list default 1`] = ` /> +
+
+
+ + +
+
+
+

+ Third party apps are installed with App Manifests. They contain UI accessible from dashboard and can extend it. Read more here. +

@@ -25071,9 +25118,9 @@ exports[`Storyshots Views / Apps / Apps list default 1`] = ` class="MuiTableCell-root-id MuiTableCell-body-id AppList-colAction-id" >
- http://localhost:3000/api/manifest + localhost:3000
- http://localhost:3000/api/manifest + localhost:3000
-
-
-
- - Internal Apps - -
- -
-
- - - - - - - -
- - app custom - - - -
-
-
@@ -25559,6 +25516,53 @@ exports[`Storyshots Views / Apps / Apps list loading 1`] = ` />
+
+
+
+ + +
+
+
+

+ Third party apps are installed with App Manifests. They contain UI accessible from dashboard and can extend it. Read more here. +

@@ -25622,68 +25626,6 @@ exports[`Storyshots Views / Apps / Apps list loading 1`] = `
-
-
-
- - Internal Apps - -
- -
-
- - - - - - -
- - ‌ - -
-
-
`; @@ -25716,6 +25658,53 @@ exports[`Storyshots Views / Apps / Apps list no data 1`] = ` /> +
+
+
+ + +
+
+
+

+ Third party apps are installed with App Manifests. They contain UI accessible from dashboard and can extend it. Read more here. +

@@ -25777,66 +25766,6 @@ exports[`Storyshots Views / Apps / Apps list no data 1`] = `
-
-
-
- - Internal Apps - -
- -
-
- - - - - - -
-
- Your custom-created apps will be shown here. -
-
-
-
`; diff --git a/webpack.config.js b/webpack.config.js index d64d9700b..967542bcd 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -39,6 +39,7 @@ const htmlWebpackPlugin = new HtmlWebpackPlugin({ const environmentPlugin = new webpack.EnvironmentPlugin({ API_URI: "", MARKETPLACE_URL: "", + SALEOR_APPS_ENDPOINT: "", APP_MOUNT_URI: "/", DEMO_MODE: false, ENVIRONMENT: "",