diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 3945601c0..ac4e707c3 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -26,9 +26,11 @@ Modify API_URI if you want test instance to use custom backend. CYPRESS_API_URI API_URI=https://automation-dashboard.staging.saleor.cloud/graphql/ MARKETPLACE_URL=https://apps.saleor.io +APPS_MARKETPLACE_API_URI=https://marketplace-gray.vercel.app/api/v2/saleor-apps 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. 1. [ ] stable @@ -55,4 +57,4 @@ To run all tests, just select the stable checkbox. To speed up tests, increase t 22. [ ] variants 23. [ ] payments -CONTAINERS=1 \ No newline at end of file +CONTAINERS=1 diff --git a/.github/workflows/deploy-cloud.yaml b/.github/workflows/deploy-cloud.yaml index dc5f9fddf..d6fee39f0 100644 --- a/.github/workflows/deploy-cloud.yaml +++ b/.github/workflows/deploy-cloud.yaml @@ -23,6 +23,7 @@ jobs: # SALEOR_APPS_PAGE_PATH=saleor-apps # SALEOR_APPS_JSON_PATH=api/saleor-apps # APP_TEMPLATE_GALLERY_PATH=template-gallery + # APPS_MARKETPLACE_API_URI: "https://apps.saleor.io/api/v2/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 1f7d87051..9cbf3e02d 100644 --- a/.github/workflows/deploy-demo-staging.yaml +++ b/.github/workflows/deploy-demo-staging.yaml @@ -26,6 +26,7 @@ jobs: # SALEOR_APPS_PAGE_PATH=saleor-apps # SALEOR_APPS_JSON_PATH=api/saleor-apps # APP_TEMPLATE_GALLERY_PATH=template-gallery + # APPS_MARKETPLACE_API_URI: "https://apps.saleor.io/api/v2/saleor-apps" ENVIRONMENT: demo-staging DEMO_MODE: true steps: diff --git a/.github/workflows/deploy-demo.yaml b/.github/workflows/deploy-demo.yaml index c15d2e35b..49f7e176f 100644 --- a/.github/workflows/deploy-demo.yaml +++ b/.github/workflows/deploy-demo.yaml @@ -21,6 +21,7 @@ jobs: # SALEOR_APPS_PAGE_PATH=saleor-apps # SALEOR_APPS_JSON_PATH=api/saleor-apps # APP_TEMPLATE_GALLERY_PATH=template-gallery + # APPS_MARKETPLACE_API_URI: "https://apps.saleor.io/api/v2/saleor-apps" ENVIRONMENT: demo DEMO_MODE: true steps: @@ -57,4 +58,4 @@ jobs: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_CLOUD_DEPLOYMENTS_WEBHOOK_URL }} JOB_TITLE: "Dashboard deployment to ${{ env.ENVIRONMENT }}" run: | - python3 ./.github/workflows/notify/notify-slack.py \ No newline at end of file + python3 ./.github/workflows/notify/notify-slack.py diff --git a/.github/workflows/deploy-master-staging.yaml b/.github/workflows/deploy-master-staging.yaml index 4b34be641..76c3ad965 100644 --- a/.github/workflows/deploy-master-staging.yaml +++ b/.github/workflows/deploy-master-staging.yaml @@ -21,6 +21,7 @@ jobs: SALEOR_APPS_PAGE_PATH: saleor-apps SALEOR_APPS_JSON_PATH: api/saleor-apps APP_TEMPLATE_GALLERY_PATH: template-gallery + APPS_MARKETPLACE_API_URI: "https://marketplace-gray.vercel.app/api/v2/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 9e10d56ab..387d84b6c 100644 --- a/.github/workflows/deploy-staging.yaml +++ b/.github/workflows/deploy-staging.yaml @@ -28,13 +28,14 @@ jobs: # SALEOR_APPS_PAGE_PATH=saleor-apps # SALEOR_APPS_JSON_PATH=api/saleor-apps # APP_TEMPLATE_GALLERY_PATH=template-gallery + # APPS_MARKETPLACE_API_URI: "https://marketplace-gray.vercel.app/api/v2/saleor-apps" VERSION: ${{ github.event.inputs.git_ref || github.ref_name }} IS_CLOUD_INSTANCE: true steps: - uses: actions/checkout@v2 with: ref: ${{ env.VERSION }} - + - name: Set environment # Convert version into staging deployment name (e.g 3.3.0 -> saleor-staging-v33) run: | diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 9a3d5ff0b..f96918389 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -108,7 +108,7 @@ jobs: pattern: (http|https)://[a-zA-Z0-9.-]+/graphql/? fallback_uri: ${{ secrets.CYPRESS_API_URI }} run: | - echo "::set-output name=custom_api_uri::$(echo $pull_request_body | grep -Eo "$prefix$pattern" | sed s/$prefix// | head -n 1 | { read custom_uri; if [ -z "$custom_uri" ]; then echo "$fallback_uri"; else echo "$custom_uri"; fi })" + echo "::set-output name=custom_api_uri::$(echo "$pull_request_body" | grep -Eo "$prefix$pattern" | sed s/$prefix// | head -n 1 | { read custom_uri; if [ -z "$custom_uri" ]; then echo "$fallback_uri"; else echo "$custom_uri"; fi })" - name: Checkout uses: actions/checkout@v2 diff --git a/.github/workflows/test-env-deploy.yml b/.github/workflows/test-env-deploy.yml index de267ea62..3d4e30837 100644 --- a/.github/workflows/test-env-deploy.yml +++ b/.github/workflows/test-env-deploy.yml @@ -58,7 +58,7 @@ jobs: prefix: API_URI= pattern: (http|https)://[a-zA-Z0-9.-]+/graphql/? run: | - echo "::set-output name=custom_api_uri::$(echo $pull_request_body | grep -Eo "$prefix$pattern" | sed s/$prefix// | head -n 1)" + echo "::set-output name=custom_api_uri::$(echo "$pull_request_body" | grep -Eo "$prefix$pattern" | sed s/$prefix// | head -n 1)" - name: Get MARKETPLACE_URL id: marketplace_url # Search for MARKETPLACE_URL in PR description @@ -67,12 +67,22 @@ jobs: prefix: MARKETPLACE_URL= pattern: (http|https)://[a-zA-Z0-9.-]+/? run: | - echo "::set-output name=custom_marketplace_url::$(echo $pull_request_body | grep -Eo "$prefix$pattern" | sed s/$prefix// | head -n 1)" + echo "::set-output name=custom_marketplace_url::$(echo "$pull_request_body" | grep -Eo "$prefix$pattern" | sed s/$prefix// | head -n 1)" + - name: Get APPS_MARKETPLACE_API_URI + id: apps_marketplace_api_uri + # Search for APPS_MARKETPLACE_API_URI in PR description + env: + pull_request_body: ${{ github.event.pull_request.body }} + prefix: APPS_MARKETPLACE_API_URI= + pattern: (http|https)://[a-zA-Z0-9.-]+[a-zA-Z0-9/-]+/? + run: | + echo "::set-output name=custom_apps_marketplace_api_uri::$(echo "$pull_request_body" | grep -Eo "$prefix$pattern" | sed s/$prefix// | head -n 1)" - name: Run build env: # Use custom API_URI or the default one API_URI: ${{ steps.api_uri.outputs.custom_api_uri || 'https://qa.staging.saleor.cloud/graphql/' }} MARKETPLACE_URL: ${{ steps.marketplace_url.outputs.custom_marketplace_url }} + APPS_MARKETPLACE_API_URI: ${{ steps.apps_marketplace_api_uri.outputs.custom_apps_marketplace_api_uri }} APP_MOUNT_URI: / STATIC_URL: / IS_CLOUD_INSTANCE: true @@ -193,7 +203,7 @@ jobs: pattern: (http|https)://[a-zA-Z0-9.-]+/graphql/? fallback_uri: ${{ secrets.CYPRESS_API_URI }} run: | - echo "::set-output name=custom_api_uri::$(echo $pull_request_body | grep -Eo "$prefix$pattern" | sed s/$prefix// | head -n 1 | { read custom_uri; if [ -z "$custom_uri" ]; then echo "$fallback_uri"; else echo "$custom_uri"; fi })" + echo "::set-output name=custom_api_uri::$(echo "$pull_request_body" | grep -Eo "$prefix$pattern" | sed s/$prefix// | head -n 1 | { read custom_uri; if [ -z "$custom_uri" ]; then echo "$fallback_uri"; else echo "$custom_uri"; fi })" - name: Setup Node uses: actions/setup-node@v3 with: diff --git a/Dockerfile b/Dockerfile index d21ad8b28..b939539a4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,6 +21,8 @@ COPY src/ src/ ARG API_URI ARG APP_MOUNT_URI ARG MARKETPLACE_URL +ARG APPS_MARKETPLACE_API_URI, +ARG APPS_TUNNEL_URL_KEYWORDS, ARG SALEOR_APPS_ENDPOINT ARG STATIC_URL ARG SKIP_SOURCEMAPS @@ -28,6 +30,8 @@ ARG SKIP_SOURCEMAPS ENV API_URI ${API_URI:-http://localhost:8000/graphql/} ENV APP_MOUNT_URI ${APP_MOUNT_URI:-/dashboard/} ENV MARKETPLACE_URL ${MARKETPLACE_URL} +ENV APPS_MARKETPLACE_API_URI ${APPS_MARKETPLACE_API_URI} +ENV APPS_TUNNEL_URL_KEYWORDS ${APPS_TUNNEL_URL_KEYWORDS} ENV SALEOR_APPS_ENDPOINT=${SALEOR_APPS_ENDPOINT} ENV STATIC_URL ${STATIC_URL:-/dashboard/} ENV SKIP_SOURCEMAPS ${SKIP_SOURCEMAPS:-true} diff --git a/docs/configuration.md b/docs/configuration.md index 5a99b7e5e..3b7ad8252 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -12,10 +12,14 @@ Create `.env` file in a root directory or set environment variables with the fol - `STATIC_URL` - URL where the static files are located. E.g., if you use an S3 bucket, you should set it to the bucket's URL. By default, Saleor assumes you serve static files from the root of your site at "http://localhost:9000/". -- `MARKETPLACE_URL` - URL where Marketplace App is located. If not found, it will not render a navigation link to the Marketplace. +- `MARKETPLACE_URL` - URL where Marketplace App is located. If not found, it will not render a navigation link to the Marketplace (deprecated in favor of `APPS_MARKETPLACE_API_URI`). -- `SALEOR_APPS_PAGE_PATH` - Path appended to `MARKETPLACE_URL` to render Saleor Apps page. +- `SALEOR_APPS_PAGE_PATH` - Path appended to `MARKETPLACE_URL` to render Saleor Apps page (deprecated). -- `SALEOR_APPS_JSON_PATH` - Path appended to `MARKETPLACE_URL` to fetch a list of Saleor Apps as JSON. +- `SALEOR_APPS_JSON_PATH` - Path appended to `MARKETPLACE_URL` to fetch a list of Saleor Apps as JSON (deprecated in favor of `APPS_MARKETPLACE_API_URI`). -- `APP_TEMPLATE_GALLERY_PATH` - Path appended to `MARKETPLACE_URL` to render App Template Gallery page. +- `APP_TEMPLATE_GALLERY_PATH` - Path appended to `MARKETPLACE_URL` to render App Template Gallery page (deprecated). + +- `APPS_MARKETPLACE_API_URI` - URI of Marketplace API to fetch list of Apps in JSON. + +- `APPS_TUNNEL_URL_KEYWORDS` - Custom apps tunnel URL keywords. diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index ec4c77ea7..905cd105e 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -70,6 +70,10 @@ "+a+2ug": { "string": "Members" }, + "+b3KCV": { + "context": "button", + "string": "Activate" + }, "+c/f61": { "context": "retry installation", "string": "Retry" @@ -200,6 +204,10 @@ "context": "section header", "string": "Media" }, + "/PhkXi": { + "context": "section header", + "string": "Installed Apps" + }, "/TF6BZ": { "string": "Search Products" }, @@ -233,6 +241,10 @@ "context": "products in collection", "string": "Products in {name}" }, + "/eTYOp": { + "context": "app support", + "string": "Support" + }, "/glQgs": { "string": "No channels found" }, @@ -378,6 +390,10 @@ "context": "selected customer channel subtitle", "string": "Customer will be sent the gift card code via this channels email address" }, + "0vsMRq": { + "context": "delete custom app", + "string": "Deleting this app, you will delete all the data and webhooks regarding this app." + }, "1+M/52": { "context": "section header", "string": "Webhook Status" @@ -617,6 +633,10 @@ "context": "button", "string": "Enable Dark Mode" }, + "2rJcFa": { + "context": "delete app", + "string": "Deleting this app, you will remove installation of the app. If you are paying for app subscription, remember to unsubscribe from the app in Saleor Marketplace." + }, "2yU+q9": { "context": "tab name", "string": "All Warehouses" @@ -784,6 +804,10 @@ "context": "order status", "string": "Returned" }, + "4IgzXs": { + "context": "label", + "string": "App Manifest URL" + }, "4JW9iJ": { "context": "home section name", "string": "Home" @@ -1326,6 +1350,10 @@ "context": "button", "string": "Back to homepage" }, + "94oZR0": { + "context": "deactivate app billing info", + "string": "You will be still billed for the app." + }, "95oJ5d": { "context": "button", "string": "Go back to dashboard" @@ -1500,6 +1528,10 @@ "context": "order discount was updated automatically event title", "string": "Order discount was updated automatically updated" }, + "ARfw53": { + "context": "description", + "string": "Install apps created by Saleor team or install external apps." + }, "AVF5T5": { "context": "voucher end date, switch button", "string": "Set end date" @@ -1849,6 +1881,9 @@ "context": "order history message", "string": "Order was refunded by {refundedBy}" }, + "D3idYv": { + "string": "Settings" + }, "D4CsYK": { "context": "status filter label", "string": "Status" @@ -2088,6 +2123,10 @@ "context": "dialog header", "string": "Select a page type" }, + "F2cR19": { + "context": "error message", + "string": "Error details: {message}" + }, "F3Upht": { "string": "Product type deleted" }, @@ -2290,6 +2329,10 @@ "context": "order history message", "string": "Order was placed" }, + "GKQGKG": { + "context": "app data privacy", + "string": "Data privacy" + }, "GLX9II": { "context": "billing address", "string": "Same as shipping address" @@ -2874,6 +2917,10 @@ "context": "dialog title", "string": "Delete products" }, + "KDvRBi": { + "context": "button", + "string": "Install" + }, "KFv8hX": { "string": "An attribute already exists." }, @@ -3117,6 +3164,10 @@ "MYA6EV": { "string": "Third party apps are installed with App Manifests. They contain UI accessible from dashboard and can extend it." }, + "MaTR88": { + "context": "header", + "string": "Integrations" + }, "MbZHXE": { "context": "column title balance", "string": "Balance" @@ -3325,6 +3376,10 @@ "O95R3Z": { "string": "Phone" }, + "O987DI": { + "context": "deactivate named app", + "string": "Are you sure you want to disable {name}? Your data will be kept until you reactivate the app." + }, "O9QPe1": { "context": "dialog content", "string": "{counter,plural,one{Are you sure you want to delete this voucher?} other{Are you sure you want to delete {displayQuantity} vouchers?}}" @@ -3804,6 +3859,10 @@ "Rj8LxK": { "string": "Add search engine title and description to make this collection easier to find" }, + "RjT+Vo": { + "context": "label", + "string": "Tunnel - development" + }, "Rjs1CD": { "context": "button", "string": "Continue" @@ -4330,6 +4389,10 @@ "context": "section header", "string": "Staff Member Information" }, + "VTq2gn": { + "context": "section header", + "string": "Coming Soon" + }, "VZsE96": { "string": "Collection Name" }, @@ -4671,6 +4734,10 @@ "Xtd0AT": { "string": "Original String" }, + "Xu4ech": { + "context": "deactivate app", + "string": "Are you sure you want to disable this app? Your data will be kept until you reactivate the app." + }, "XwQQ1f": { "context": "checkbox label description", "string": "All fulfillments will be automatically approved" @@ -5252,6 +5319,10 @@ "context": "input label", "string": "New Password" }, + "cMov2n": { + "context": "error message", + "string": "Error loading apps marketplace" + }, "cNSLLO": { "context": "button", "string": "Unassign and save" @@ -5509,6 +5580,10 @@ "context": "attributes, section header", "string": "Variant Attributes" }, + "f91E8b": { + "context": "app repository", + "string": "Repository" + }, "fEfCtO": { "context": "voucher discount type", "string": "Percentage" @@ -5703,6 +5778,10 @@ "gxPjIQ": { "string": "Are you sure you want to delete {email} from staff members?" }, + "gygOA1": { + "context": "button", + "string": "Deactivate" + }, "gz9v22": { "context": "PluginChannelConfigurationCell channel title", "string": "Per channel" @@ -6146,6 +6225,10 @@ "context": "channel publication date", "string": "Published since {date}" }, + "kYtxJ1": { + "context": "delete app", + "string": "Deleting {name}, you will remove installation of the app. If you are paying for app subscription, remember to unsubscribe from the app in Saleor Marketplace." + }, "kZfIl/": { "string": "These are general information about this Content Type." }, @@ -6451,6 +6534,10 @@ "context": "money amount input label", "string": "Enter amount" }, + "nA2g2e": { + "context": "label", + "string": "External app" + }, "nEWp+k": { "context": "quantity of ordered products", "string": "Quantity" @@ -6513,6 +6600,10 @@ "context": "tax class rates list label when no countries are assigned", "string": "There are no countries using this tax class yet, use {tab} tab to assign tax rates." }, + "nioOBQ": { + "context": "delete custom app", + "string": "Deleting {name}, you will delete all the data and webhooks regarding this app." + }, "njBulj": { "context": "check to require attribute to have value", "string": "Value Required" @@ -6575,6 +6666,10 @@ "context": "order status", "string": "Unfulfilled" }, + "oGIPb7": { + "context": "information", + "string": "Coming soon ({releaseDate})" + }, "oHbgcK": { "context": "PageTypeDeleteWarningDialog title", "string": "Delete page {selectedTypesCount,plural,one{type} other{types}}" @@ -6619,6 +6714,10 @@ "oYGfnY": { "string": "ZIP / Postal code" }, + "ohQrzn": { + "context": "button", + "string": "Deploy to Vercel" + }, "oiuwOl": { "context": "button", "string": "Assign" @@ -6752,6 +6851,10 @@ "context": "card subtitle", "string": "Entered prices" }, + "puUvKO": { + "context": "button", + "string": "Install external app" + }, "puikeb": { "context": "button", "string": "Delete Address" @@ -7857,6 +7960,10 @@ "yT5zvU": { "string": "{counter,plural,one{Are you sure you want to delete this collection?} other{Are you sure you want to delete {displayQuantity} collections?}}" }, + "yZB41s": { + "context": "section header", + "string": "All Apps" + }, "yatGsm": { "context": "card title", "string": "Order Value" @@ -7911,6 +8018,10 @@ "context": "file attribute type", "string": "File" }, + "z2yYL3": { + "context": "hint", + "string": "Usually ends with /api/manifest" + }, "z8jo8h": { "context": "button", "string": "View products" diff --git a/src/apps/fixtures.ts b/src/apps/fixtures.ts index 02f40b359..a095b35fc 100644 --- a/src/apps/fixtures.ts +++ b/src/apps/fixtures.ts @@ -15,6 +15,7 @@ export const appsList: AppListItemFragment[] = [ isActive: true, name: "app", type: AppTypeEnum.THIRDPARTY, + version: "1.0.0", appUrl: null, manifestUrl: "http://localhost:3000/api/manifest", permissions: [ @@ -31,6 +32,7 @@ export const appsList: AppListItemFragment[] = [ isActive: false, name: "app1", type: AppTypeEnum.THIRDPARTY, + version: "1.0.0", appUrl: "http://localhost:3000", manifestUrl: "http://localhost:3000/api/manifest", permissions: [ @@ -55,6 +57,7 @@ export const customAppsList: AppListItemFragment[] = [ isActive: true, name: "app custom", type: AppTypeEnum.LOCAL, + version: "1.0.0", appUrl: null, manifestUrl: null, permissions: [ diff --git a/src/apps/mutations.ts b/src/apps/mutations.ts index 6e96f8e53..f3e5f8819 100644 --- a/src/apps/mutations.ts +++ b/src/apps/mutations.ts @@ -47,21 +47,7 @@ export const appFetchMutation = gql` mutation AppFetch($manifestUrl: String!) { appFetchManifest(manifestUrl: $manifestUrl) { manifest { - identifier - version - about - name - appUrl - configurationUrl - tokenTargetUrl - dataPrivacy - dataPrivacyUrl - homepageUrl - supportUrl - permissions { - code - name - } + ...AppManifest } errors { ...AppError diff --git a/src/components/TableButtonWrapper/TableButtonWrapper.tsx b/src/components/TableButtonWrapper/TableButtonWrapper.tsx index 74140bda8..fcf3f62f9 100644 --- a/src/components/TableButtonWrapper/TableButtonWrapper.tsx +++ b/src/components/TableButtonWrapper/TableButtonWrapper.tsx @@ -42,3 +42,5 @@ export const TableButtonWrapper = ({ return children; }; +TableButtonWrapper.displayName = "TableButtonWrapper"; +export default TableButtonWrapper; diff --git a/src/components/TableButtonWrapper/index.ts b/src/components/TableButtonWrapper/index.ts new file mode 100644 index 000000000..fdb121f10 --- /dev/null +++ b/src/components/TableButtonWrapper/index.ts @@ -0,0 +1,2 @@ +export { default } from "./TableButtonWrapper"; +export * from "./TableButtonWrapper"; diff --git a/src/components/TableButtonWrapper/index.tsx b/src/components/TableButtonWrapper/index.tsx deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/components/TableCellAvatar/Avatar.tsx b/src/components/TableCellAvatar/Avatar.tsx index d32b057be..1b3ee1e0d 100644 --- a/src/components/TableCellAvatar/Avatar.tsx +++ b/src/components/TableCellAvatar/Avatar.tsx @@ -1,44 +1,13 @@ -import { Avatar as MuiAvatar } from "@material-ui/core"; -import { ImageIcon, makeStyles } from "@saleor/macaw-ui"; import clsx from "clsx"; import React from "react"; +import AvatarImage from "./AvatarImage"; +import { useAvatarStyles } from "./styles"; + export const AVATAR_MARGIN = 40; -const useStyles = makeStyles( - theme => ({ - alignRight: { - justifyContent: "flex-end", - }, - avatar: { - background: "none", - border: `1px solid ${theme.palette.divider}`, - borderRadius: 2, - color: "#bdbdbd", - display: "inline-flex", - padding: theme.spacing(0.5), - }, - children: { - alignSelf: "center", - marginLeft: theme.spacing(2), - width: "100%", - }, - content: { - alignItems: "center", - display: "flex", - }, - root: { - "&:not(first-child)": { - paddingLeft: 0, - }, - paddingRight: theme.spacing(3), - width: "1%", - }, - }), - { name: "Avatar" }, -); - export interface AvatarProps { + initials?: string; thumbnail?: string; alignRight?: boolean; avatarProps?: string; @@ -49,11 +18,12 @@ export interface AvatarProps { const Avatar: React.FC = ({ children, alignRight, + initials, thumbnail, avatarProps, badge, }) => { - const classes = useStyles({}); + const classes = useAvatarStyles(); return (
= ({ })} > {badge} - {!thumbnail ? ( - - - - ) : ( - - )} + {!alignRight &&
{children}
}
); diff --git a/src/components/TableCellAvatar/AvatarImage.tsx b/src/components/TableCellAvatar/AvatarImage.tsx new file mode 100644 index 000000000..dc4978342 --- /dev/null +++ b/src/components/TableCellAvatar/AvatarImage.tsx @@ -0,0 +1,42 @@ +import { Avatar as MuiAvatar, Typography } from "@material-ui/core"; +import { ImageIcon } from "@saleor/macaw-ui"; +import clsx from "clsx"; +import React from "react"; + +import { useAvatarImageStyles } from "./styles"; + +interface AvatarImageProps { + initials?: string; + thumbnail?: string; + avatarProps?: string; +} + +const AvatarImage: React.FC = ({ + initials, + thumbnail, + avatarProps, +}) => { + const classes = useAvatarImageStyles(); + + if (!thumbnail && initials) { + return ( + + {initials} + + ); + } + + if (!thumbnail) { + return ( + + + + ); + } + + return ( + + ); +}; + +export default AvatarImage; diff --git a/src/components/TableCellAvatar/TableCellAvatar.tsx b/src/components/TableCellAvatar/TableCellAvatar.tsx index 927a324bf..30031ec0b 100644 --- a/src/components/TableCellAvatar/TableCellAvatar.tsx +++ b/src/components/TableCellAvatar/TableCellAvatar.tsx @@ -1,23 +1,10 @@ import { TableCell } from "@material-ui/core"; import { TableCellProps } from "@material-ui/core/TableCell"; -import { makeStyles } from "@saleor/macaw-ui"; import clsx from "clsx"; import React from "react"; import Avatar, { AvatarProps } from "./Avatar"; - -const useStyles = makeStyles( - theme => ({ - root: { - "&:not(first-child)": { - paddingLeft: 0, - }, - paddingRight: theme.spacing(3), - width: "1%", - }, - }), - { name: "TableCellAvatar" }, -); +import { useStyles } from "./styles"; interface TableCellAvatarProps extends TableCellProps, diff --git a/src/components/TableCellAvatar/styles.ts b/src/components/TableCellAvatar/styles.ts new file mode 100644 index 000000000..c14799808 --- /dev/null +++ b/src/components/TableCellAvatar/styles.ts @@ -0,0 +1,53 @@ +import { makeStyles } from "@saleor/macaw-ui"; + +export const useStyles = makeStyles( + theme => ({ + root: { + "&:not(first-child)": { + paddingLeft: 0, + }, + paddingRight: theme.spacing(3), + width: "1%", + }, + }), + { name: "TableCellAvatar" }, +); + +export const useAvatarStyles = makeStyles( + theme => ({ + alignRight: { + justifyContent: "flex-end", + }, + children: { + alignSelf: "center", + marginLeft: theme.spacing(2), + width: "100%", + }, + content: { + alignItems: "center", + display: "flex", + }, + root: { + "&:not(first-child)": { + paddingLeft: 0, + }, + paddingRight: theme.spacing(3), + width: "1%", + }, + }), + { name: "Avatar" }, +); + +export const useAvatarImageStyles = makeStyles( + theme => ({ + avatar: { + background: "none", + border: `1px solid ${theme.palette.divider}`, + borderRadius: 2, + color: theme.palette.grey[500], + display: "inline-flex", + padding: theme.spacing(0.5), + }, + }), + { name: "AvatarImage" }, +); diff --git a/src/config.ts b/src/config.ts index 7d8a3502d..fc1e093c2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -16,6 +16,13 @@ export const MARKETPLACE_SALEOR_APPS_JSON_PATH = export const MARKETPLACE_APP_TEMPLATE_GALLERY_PATH = window.__SALEOR_CONFIG__.APP_TEMPLATE_GALLERY_PATH; +export const getAppsConfig = () => ({ + marketplaceApiUri: window.__SALEOR_CONFIG__.APPS_MARKETPLACE_API_URI, + tunnelUrlKeywords: window.__SALEOR_CONFIG__.APPS_TUNNEL_URL_KEYWORDS?.split( + ";", + ) || [".ngrok.io", ".saleor.live", ".trycloudflare.com"], +}); + export const DEFAULT_INITIAL_SEARCH_DATA: SearchVariables = { after: null, first: 20, diff --git a/src/fragments/apps.ts b/src/fragments/apps.ts index f7300ea3d..6bd7abaab 100644 --- a/src/fragments/apps.ts +++ b/src/fragments/apps.ts @@ -1,5 +1,25 @@ import { gql } from "@apollo/client"; +export const appManifestFragment = gql` + fragment AppManifest on Manifest { + identifier + version + about + name + appUrl + configurationUrl + tokenTargetUrl + dataPrivacy + dataPrivacyUrl + homepageUrl + supportUrl + permissions { + code + name + } + } +`; + export const appFragment = gql` fragment App on App { id @@ -41,6 +61,7 @@ export const appListItemFragment = gql` type appUrl manifestUrl + version permissions { ...AppPermission } diff --git a/src/graphql/hooks.generated.ts b/src/graphql/hooks.generated.ts index 3f9e671be..ee48e9e9d 100644 --- a/src/graphql/hooks.generated.ts +++ b/src/graphql/hooks.generated.ts @@ -5,6 +5,25 @@ import { gql } from '@apollo/client'; import * as Apollo from '@apollo/client'; import * as ApolloReactHooks from '@saleor/hooks/graphql'; const defaultOptions = {} as const; +export const AppManifestFragmentDoc = gql` + fragment AppManifest on Manifest { + identifier + version + about + name + appUrl + configurationUrl + tokenTargetUrl + dataPrivacy + dataPrivacyUrl + homepageUrl + supportUrl + permissions { + code + name + } +} + `; export const WebhookFragmentDoc = gql` fragment Webhook on Webhook { id @@ -62,6 +81,7 @@ export const AppListItemFragmentDoc = gql` type appUrl manifestUrl + version permissions { ...AppPermission } @@ -2921,28 +2941,15 @@ export const AppFetchDocument = gql` mutation AppFetch($manifestUrl: String!) { appFetchManifest(manifestUrl: $manifestUrl) { manifest { - identifier - version - about - name - appUrl - configurationUrl - tokenTargetUrl - dataPrivacy - dataPrivacyUrl - homepageUrl - supportUrl - permissions { - code - name - } + ...AppManifest } errors { ...AppError } } } - ${AppErrorFragmentDoc}`; + ${AppManifestFragmentDoc} +${AppErrorFragmentDoc}`; export type AppFetchMutationFn = Apollo.MutationFunction; /** diff --git a/src/graphql/types.generated.ts b/src/graphql/types.generated.ts index 543e71f5d..5ccc0d74a 100644 --- a/src/graphql/types.generated.ts +++ b/src/graphql/types.generated.ts @@ -6332,7 +6332,7 @@ export type AppsListQueryVariables = Exact<{ }>; -export type AppsListQuery = { __typename: 'Query', apps: { __typename: 'AppCountableConnection', totalCount: number | null, pageInfo: { __typename: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor: string | null, endCursor: string | null }, edges: Array<{ __typename: 'AppCountableEdge', node: { __typename: 'App', id: string, name: string | null, isActive: boolean | null, type: AppTypeEnum | null, appUrl: string | null, manifestUrl: string | null, permissions: Array<{ __typename: 'Permission', name: string, code: PermissionEnum }> | null } }> } | null }; +export type AppsListQuery = { __typename: 'Query', apps: { __typename: 'AppCountableConnection', totalCount: number | null, pageInfo: { __typename: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor: string | null, endCursor: string | null }, edges: Array<{ __typename: 'AppCountableEdge', node: { __typename: 'App', id: string, name: string | null, isActive: boolean | null, type: AppTypeEnum | null, appUrl: string | null, manifestUrl: string | null, version: string | null, permissions: Array<{ __typename: 'Permission', name: string, code: PermissionEnum }> | null } }> } | null }; export type AppsInstallationsQueryVariables = Exact<{ [key: string]: never; }>; @@ -7053,9 +7053,11 @@ export type FileUploadMutation = { __typename: 'Mutation', fileUpload: { __typen export type AddressFragment = { __typename: 'Address', city: string, cityArea: string, companyName: string, countryArea: string, firstName: string, id: string, lastName: string, phone: string | null, postalCode: string, streetAddress1: string, streetAddress2: string, country: { __typename: 'CountryDisplay', code: string, country: string } }; +export type AppManifestFragment = { __typename: 'Manifest', identifier: string, version: string, about: string | null, name: string, appUrl: string | null, configurationUrl: string | null, tokenTargetUrl: string | null, dataPrivacy: string | null, dataPrivacyUrl: string | null, homepageUrl: string | null, supportUrl: string | null, permissions: Array<{ __typename: 'Permission', code: PermissionEnum, name: string }> | null }; + export type AppFragment = { __typename: 'App', id: string, name: string | null, created: any | null, isActive: boolean | null, type: AppTypeEnum | null, homepageUrl: string | null, appUrl: string | null, manifestUrl: string | null, configurationUrl: string | null, supportUrl: string | null, version: string | null, accessToken: string | null, privateMetadata: Array<{ __typename: 'MetadataItem', key: string, value: string }>, metadata: Array<{ __typename: 'MetadataItem', key: string, value: string }>, tokens: Array<{ __typename: 'AppToken', authToken: string | null, id: string, name: string | null }> | null, webhooks: Array<{ __typename: 'Webhook', id: string, name: string, isActive: boolean, app: { __typename: 'App', id: string, name: string | null } }> | null }; -export type AppListItemFragment = { __typename: 'App', id: string, name: string | null, isActive: boolean | null, type: AppTypeEnum | null, appUrl: string | null, manifestUrl: string | null, permissions: Array<{ __typename: 'Permission', name: string, code: PermissionEnum }> | null }; +export type AppListItemFragment = { __typename: 'App', id: string, name: string | null, isActive: boolean | null, type: AppTypeEnum | null, appUrl: string | null, manifestUrl: string | null, version: string | null, permissions: Array<{ __typename: 'Permission', name: string, code: PermissionEnum }> | null }; export type AppPermissionFragment = { __typename: 'Permission', name: string, code: PermissionEnum }; diff --git a/src/hooks/useLocalPaginator.ts b/src/hooks/useLocalPaginator.ts index c198b39e9..c64390105 100644 --- a/src/hooks/useLocalPaginator.ts +++ b/src/hooks/useLocalPaginator.ts @@ -1,10 +1,10 @@ import { useEffect, useState } from "react"; export interface PageInfo { - endCursor: string; + endCursor: string | null; hasNextPage: boolean; hasPreviousPage: boolean; - startCursor: string; + startCursor: string | null; } export interface PaginationState { @@ -91,11 +91,14 @@ export function useSectionLocalPaginationState( function useLocalPaginator( setPaginationState: (paginationState: PaginationState) => void, ) { - function paginate(pageInfo: PageInfo, paginationState: PaginationState) { + function paginate( + pageInfo: PageInfo | undefined, + paginationState: PaginationState, + ) { const loadNextPage = () => setPaginationState({ ...paginationState, - after: pageInfo.endCursor, + after: pageInfo?.endCursor, before: undefined, }); @@ -103,7 +106,7 @@ function useLocalPaginator( setPaginationState({ ...paginationState, after: undefined, - before: pageInfo.startCursor, + before: pageInfo?.startCursor, }); const newPageInfo = pageInfo diff --git a/src/hooks/usePaginator.ts b/src/hooks/usePaginator.ts index ce628c582..79f24284a 100644 --- a/src/hooks/usePaginator.ts +++ b/src/hooks/usePaginator.ts @@ -102,8 +102,8 @@ export default usePaginator; export interface PaginatorContextValuesCommon { hasNextPage?: boolean; hasPreviousPage?: boolean; - endCursor?: string; - startCursor?: string; + endCursor?: string | null; + startCursor?: string | null; } export type PaginatorContextValues = PaginatorContextValuesCommon & diff --git a/src/index.html b/src/index.html index 9d84ba38a..3b81a38d5 100644 --- a/src/index.html +++ b/src/index.html @@ -16,6 +16,8 @@ SALEOR_APPS_PAGE_PATH: "<%= SALEOR_APPS_PAGE_PATH %>", SALEOR_APPS_JSON_PATH: "<%= SALEOR_APPS_JSON_PATH %>", APP_TEMPLATE_GALLERY_PATH: "<%= APP_TEMPLATE_GALLERY_PATH %>", + APPS_MARKETPLACE_API_URI: "<%= APPS_MARKETPLACE_API_URI %>", + APPS_TUNNEL_URL_KEYWORDS: "<%= APPS_TUNNEL_URL_KEYWORDS %>", }; diff --git a/src/index.tsx b/src/index.tsx index 50243094e..52d75916d 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -55,6 +55,8 @@ import MarketplaceSection from "./marketplace"; import { marketplaceUrl } from "./marketplace/urls"; import NavigationSection from "./navigation"; import { navigationSection } from "./navigation/urls"; +import NewAppsSection from "./new-apps"; +import { AppSections } from "./new-apps/urls"; import { NotFound } from "./NotFound"; import OrdersSection from "./orders"; import PageSection from "./pages"; @@ -261,6 +263,10 @@ const Routes: React.FC = () => { path={appsSection} component={AppsSection} /> + void; + navigateToVercelDeploymentPage?: (vercelDeploymentUrl: string) => void; +} + +const AllAppList: React.FC = ({ + appList, + navigateToAppInstallPage, + navigateToVercelDeploymentPage, +}) => { + const classes = useStyles(); + + if (!appList) { + return ; + } + + return ( +
+ {appList.map(app => ( + + ))} +
+ ); +}; + +export default AllAppList; diff --git a/src/new-apps/components/AllAppList/index.ts b/src/new-apps/components/AllAppList/index.ts new file mode 100644 index 000000000..c70b8823f --- /dev/null +++ b/src/new-apps/components/AllAppList/index.ts @@ -0,0 +1,2 @@ +export * from "./AllAppList"; +export { default } from "./AllAppList"; diff --git a/src/new-apps/components/AllAppList/styles.ts b/src/new-apps/components/AllAppList/styles.ts new file mode 100644 index 000000000..5020adfc1 --- /dev/null +++ b/src/new-apps/components/AllAppList/styles.ts @@ -0,0 +1,15 @@ +import { makeStyles } from "@saleor/macaw-ui"; + +export const useStyles = makeStyles( + theme => ({ + appListWrapper: { + display: "grid", + gridTemplateColumns: `repeat(auto-fit, minmax(400px, 1fr))`, + gap: theme.spacing(2), + marginTop: theme.spacing(1), + }, + }), + { + name: "AllAppList", + }, +); diff --git a/src/new-apps/components/AppActivateDialog/AppActivateDialog.stories.tsx b/src/new-apps/components/AppActivateDialog/AppActivateDialog.stories.tsx new file mode 100644 index 000000000..27816d680 --- /dev/null +++ b/src/new-apps/components/AppActivateDialog/AppActivateDialog.stories.tsx @@ -0,0 +1,18 @@ +import Decorator from "@saleor/storybook/Decorator"; +import { storiesOf } from "@storybook/react"; +import React from "react"; + +import AppActivateDialog, { AppActivateDialogProps } from "./AppActivateDialog"; + +const props: AppActivateDialogProps = { + confirmButtonState: "default", + name: "App", + onClose: () => undefined, + onConfirm: () => undefined, + open: true, +}; + +storiesOf("Views / Apps / Activate app", module) + .addDecorator(Decorator) + .add("default", () => ) + .add("unnamed app", () => ); diff --git a/src/new-apps/components/AppActivateDialog/AppActivateDialog.test.tsx b/src/new-apps/components/AppActivateDialog/AppActivateDialog.test.tsx new file mode 100644 index 000000000..e4dfae663 --- /dev/null +++ b/src/new-apps/components/AppActivateDialog/AppActivateDialog.test.tsx @@ -0,0 +1,72 @@ +import Wrapper from "@test/wrapper"; +import { render, screen } from "@testing-library/react"; +import React from "react"; + +import AppActivateDialog from "./AppActivateDialog"; +import msgs from "./messages"; + +describe("Apps AppActivateDialog", () => { + it("displays action text with app name when app name passed", () => { + // Arrange + const name = "Test App"; + render( + + + , + ); + const dialogContent = screen.getByTestId("dialog-content"); + + // Assert + const expectedText = msgs.activateNamedApp.defaultMessage.replace( + "{name}", + name, + ); + expect(dialogContent).toHaveTextContent(expectedText); + }); + + it("displays action text without app name when app name is empty", () => { + // Arrange + render( + + + , + ); + const dialogContent = screen.getByTestId("dialog-content"); + + // Assert + const expectedText = msgs.activateApp.defaultMessage; + expect(dialogContent).toHaveTextContent(expectedText); + }); + + it("displays action text without app name when app name is null", () => { + // Arrange + render( + + + , + ); + const dialogContent = screen.getByTestId("dialog-content"); + + // Assert + const expectedText = msgs.activateApp.defaultMessage; + expect(dialogContent).toHaveTextContent(expectedText); + }); +}); diff --git a/src/new-apps/components/AppActivateDialog/AppActivateDialog.tsx b/src/new-apps/components/AppActivateDialog/AppActivateDialog.tsx new file mode 100644 index 000000000..b5ffa9365 --- /dev/null +++ b/src/new-apps/components/AppActivateDialog/AppActivateDialog.tsx @@ -0,0 +1,56 @@ +import { DialogContentText } from "@material-ui/core"; +import ActionDialog from "@saleor/components/ActionDialog"; +import { buttonMessages } from "@saleor/intl"; +import { ConfirmButtonTransitionState } from "@saleor/macaw-ui"; +import { getStringOrPlaceholder } from "@saleor/misc"; +import React from "react"; +import { useIntl } from "react-intl"; + +import msgs from "./messages"; + +export interface AppActivateDialogProps { + confirmButtonState: ConfirmButtonTransitionState; + open: boolean; + name?: string | null; + onClose: () => void; + onConfirm: () => void; +} + +const AppActivateDialog: React.FC = ({ + confirmButtonState, + open, + name, + onClose, + onConfirm, +}) => { + const intl = useIntl(); + + const missingName = name === null || name === ""; + + const getMainText = () => { + if (missingName) { + return intl.formatMessage(msgs.activateApp); + } + return intl.formatMessage(msgs.activateNamedApp, { + name: {getStringOrPlaceholder(name)}, + }); + }; + + return ( + + + {getMainText()} + + + ); +}; +AppActivateDialog.displayName = "AppActivateDialog"; +export default AppActivateDialog; diff --git a/src/new-apps/components/AppActivateDialog/index.ts b/src/new-apps/components/AppActivateDialog/index.ts new file mode 100644 index 000000000..7b7d24553 --- /dev/null +++ b/src/new-apps/components/AppActivateDialog/index.ts @@ -0,0 +1,2 @@ +export * from "./AppActivateDialog"; +export { default } from "./AppActivateDialog"; diff --git a/src/new-apps/components/AppActivateDialog/messages.ts b/src/new-apps/components/AppActivateDialog/messages.ts new file mode 100644 index 000000000..c70a1b050 --- /dev/null +++ b/src/new-apps/components/AppActivateDialog/messages.ts @@ -0,0 +1,21 @@ +import { defineMessages } from "react-intl"; + +export default defineMessages({ + activateAppTitle: { + id: "YHNozE", + defaultMessage: "Activate App", + description: "dialog header", + }, + activateApp: { + id: "Q47ovw", + defaultMessage: + "Are you sure you want to activate this app? Activating will start gathering events.", + description: "activate app", + }, + activateNamedApp: { + id: "JbUg2b", + defaultMessage: + "Are you sure you want to activate {name}? Activating will start gathering events.", + description: "activate app", + }, +}); diff --git a/src/new-apps/components/AppDeactivateDialog/AppDeactivateDialog.stories.tsx b/src/new-apps/components/AppDeactivateDialog/AppDeactivateDialog.stories.tsx new file mode 100644 index 000000000..456e1db3c --- /dev/null +++ b/src/new-apps/components/AppDeactivateDialog/AppDeactivateDialog.stories.tsx @@ -0,0 +1,20 @@ +import Decorator from "@saleor/storybook/Decorator"; +import { storiesOf } from "@storybook/react"; +import React from "react"; + +import AppDeactivateDialog, { + AppDeactivateDialogProps, +} from "./AppDeactivateDialog"; + +const props: AppDeactivateDialogProps = { + confirmButtonState: "default", + name: "App", + onClose: () => undefined, + onConfirm: () => undefined, + open: true, +}; + +storiesOf("Views / Apps / Deactivate app", module) + .addDecorator(Decorator) + .add("default", () => ) + .add("unnamed app", () => ); diff --git a/src/new-apps/components/AppDeactivateDialog/AppDeactivateDialog.test.tsx b/src/new-apps/components/AppDeactivateDialog/AppDeactivateDialog.test.tsx new file mode 100644 index 000000000..20b7d0617 --- /dev/null +++ b/src/new-apps/components/AppDeactivateDialog/AppDeactivateDialog.test.tsx @@ -0,0 +1,123 @@ +import Wrapper from "@test/wrapper"; +import { render, screen } from "@testing-library/react"; +import React from "react"; + +import AppDeactivateDialog from "./AppDeactivateDialog"; +import msgs from "./messages"; + +describe("Apps AppDeactivateDialog", () => { + it("displays action text with app name when third-party app name passed", () => { + // Arrange + const name = "Test App"; + render( + + + , + ); + const dialogContent = screen.getByTestId("dialog-content"); + + // Assert + const expectedActionText = msgs.deactivateNamedApp.defaultMessage.replace( + "{name}", + name, + ); + const expectedBillingWarning = msgs.deactivateAppBillingInfo.defaultMessage; + expect(dialogContent).toHaveTextContent(expectedActionText); + expect(dialogContent).toHaveTextContent(expectedBillingWarning); + }); + + it("displays action text without app name when third-party app name is empty", () => { + // Arrange + render( + + + , + ); + const dialogContent = screen.getByTestId("dialog-content"); + + // Assert + const expectedText = msgs.deactivateApp.defaultMessage; + const expectedBillingWarning = msgs.deactivateAppBillingInfo.defaultMessage; + expect(dialogContent).toHaveTextContent(expectedText); + expect(dialogContent).toHaveTextContent(expectedBillingWarning); + }); + + it("displays action text without app name when third-party app name is null", () => { + // Arrange + render( + + + , + ); + const dialogContent = screen.getByTestId("dialog-content"); + + // Assert + const expectedText = msgs.deactivateApp.defaultMessage; + const expectedBillingWarning = msgs.deactivateAppBillingInfo.defaultMessage; + expect(dialogContent).toHaveTextContent(expectedText); + expect(dialogContent).toHaveTextContent(expectedBillingWarning); + }); + + it("displays billing warning when app is marked explicitly as third-party", () => { + // Arrange + const name = "Test App"; + render( + + + , + ); + const dialogContent = screen.getByTestId("dialog-content"); + + // Assert + const expectedBillingWarning = msgs.deactivateAppBillingInfo.defaultMessage; + expect(dialogContent).toHaveTextContent(expectedBillingWarning); + }); + + it("doesn't display billing warning when app is marked explicitly as not third-party", () => { + // Arrange + const name = "Test App"; + render( + + + , + ); + const dialogContent = screen.getByTestId("dialog-content"); + + // Assert + const notExpectedBillingWarning = + msgs.deactivateAppBillingInfo.defaultMessage; + expect(dialogContent).not.toHaveTextContent(notExpectedBillingWarning); + }); +}); diff --git a/src/new-apps/components/AppDeactivateDialog/AppDeactivateDialog.tsx b/src/new-apps/components/AppDeactivateDialog/AppDeactivateDialog.tsx new file mode 100644 index 000000000..7d36a6dde --- /dev/null +++ b/src/new-apps/components/AppDeactivateDialog/AppDeactivateDialog.tsx @@ -0,0 +1,64 @@ +import { DialogContentText } from "@material-ui/core"; +import ActionDialog from "@saleor/components/ActionDialog"; +import { buttonMessages } from "@saleor/intl"; +import { ConfirmButtonTransitionState } from "@saleor/macaw-ui"; +import { getStringOrPlaceholder } from "@saleor/misc"; +import React from "react"; +import { FormattedMessage, useIntl } from "react-intl"; + +import msgs from "./messages"; + +export interface AppDeactivateDialogProps { + confirmButtonState: ConfirmButtonTransitionState; + open: boolean; + name?: string | null; + thirdParty?: boolean; + onClose: () => void; + onConfirm: () => void; +} + +const AppDeactivateDialog: React.FC = ({ + confirmButtonState, + open, + name, + thirdParty = true, + onClose, + onConfirm, +}) => { + const intl = useIntl(); + + const missingName = name === null || name === ""; + + const getMainText = () => { + if (missingName) { + return intl.formatMessage(msgs.deactivateApp); + } + return intl.formatMessage(msgs.deactivateNamedApp, { + name: {getStringOrPlaceholder(name)}, + }); + }; + + return ( + + + {getMainText()} + {thirdParty && ( + <> + {" "} + + + )} + + + ); +}; +AppDeactivateDialog.displayName = "AppDeactivateDialog"; +export default AppDeactivateDialog; diff --git a/src/new-apps/components/AppDeactivateDialog/index.ts b/src/new-apps/components/AppDeactivateDialog/index.ts new file mode 100644 index 000000000..d58865cf1 --- /dev/null +++ b/src/new-apps/components/AppDeactivateDialog/index.ts @@ -0,0 +1,2 @@ +export * from "./AppDeactivateDialog"; +export { default } from "./AppDeactivateDialog"; diff --git a/src/new-apps/components/AppDeactivateDialog/messages.ts b/src/new-apps/components/AppDeactivateDialog/messages.ts new file mode 100644 index 000000000..47633779b --- /dev/null +++ b/src/new-apps/components/AppDeactivateDialog/messages.ts @@ -0,0 +1,26 @@ +import { defineMessages } from "react-intl"; + +export default defineMessages({ + deactivateAppTitle: { + id: "yMi8I8", + defaultMessage: "Dectivate App", + description: "dialog header", + }, + deactivateApp: { + id: "Xu4ech", + defaultMessage: + "Are you sure you want to disable this app? Your data will be kept until you reactivate the app.", + description: "deactivate app", + }, + deactivateNamedApp: { + id: "O987DI", + defaultMessage: + "Are you sure you want to disable {name}? Your data will be kept until you reactivate the app.", + description: "deactivate named app", + }, + deactivateAppBillingInfo: { + id: "94oZR0", + defaultMessage: "You will be still billed for the app.", + description: "deactivate app billing info", + }, +}); diff --git a/src/new-apps/components/AppDeleteDialog/AppDeleteDialog.stories.tsx b/src/new-apps/components/AppDeleteDialog/AppDeleteDialog.stories.tsx new file mode 100644 index 000000000..cf9e00eba --- /dev/null +++ b/src/new-apps/components/AppDeleteDialog/AppDeleteDialog.stories.tsx @@ -0,0 +1,19 @@ +import Decorator from "@saleor/storybook/Decorator"; +import { storiesOf } from "@storybook/react"; +import React from "react"; + +import AppDeleteDialog, { AppDeleteDialogProps } from "./AppDeleteDialog"; + +const props: AppDeleteDialogProps = { + confirmButtonState: "default", + name: "App", + onClose: () => undefined, + onConfirm: () => undefined, + open: true, + type: "EXTERNAL", +}; + +storiesOf("Views / Apps / Delete app", module) + .addDecorator(Decorator) + .add("default", () => ) + .add("unnamed app", () => ); diff --git a/src/new-apps/components/AppDeleteDialog/AppDeleteDialog.test.tsx b/src/new-apps/components/AppDeleteDialog/AppDeleteDialog.test.tsx new file mode 100644 index 000000000..067c9d54c --- /dev/null +++ b/src/new-apps/components/AppDeleteDialog/AppDeleteDialog.test.tsx @@ -0,0 +1,154 @@ +import Wrapper from "@test/wrapper"; +import { render, screen } from "@testing-library/react"; +import React from "react"; + +import AppDeleteDialog from "./AppDeleteDialog"; +import msgs from "./messages"; + +describe("Apps AppDeleteDialog", () => { + it("displays external app action text with app name when external app with app name passed", () => { + // Arrange + const name = "Test App"; + render( + + + , + ); + const dialogContent = screen.getByTestId("dialog-content"); + + // Assert + const expectedText = msgs.deleteNamedApp.defaultMessage.replace( + "{name}", + name, + ); + const expectedQuestion = msgs.deleteAppQuestion.defaultMessage; + expect(dialogContent).toHaveTextContent(expectedText); + expect(dialogContent).toHaveTextContent(expectedQuestion); + }); + + it("displays custom app action text with app name when custom app with app name passed", () => { + // Arrange + const name = "Test App"; + render( + + + , + ); + const dialogContent = screen.getByTestId("dialog-content"); + + // Assert + const expectedText = msgs.deleteLocalNamedApp.defaultMessage.replace( + "{name}", + name, + ); + const expectedQuestion = msgs.deleteAppQuestion.defaultMessage; + expect(dialogContent).toHaveTextContent(expectedText); + expect(dialogContent).toHaveTextContent(expectedQuestion); + }); + + it("displays external action text without app name when external app name is empty", () => { + // Arrange + render( + + + , + ); + const dialogContent = screen.getByTestId("dialog-content"); + + // Assert + const expectedText = msgs.deleteApp.defaultMessage; + const expectedQuestion = msgs.deleteAppQuestion.defaultMessage; + expect(dialogContent).toHaveTextContent(expectedText); + expect(dialogContent).toHaveTextContent(expectedQuestion); + }); + + it("displays custom action text without app name when custom app name is empty", () => { + // Arrange + render( + + + , + ); + const dialogContent = screen.getByTestId("dialog-content"); + + // Assert + const expectedText = msgs.deleteLocalApp.defaultMessage; + const expectedQuestion = msgs.deleteAppQuestion.defaultMessage; + expect(dialogContent).toHaveTextContent(expectedText); + expect(dialogContent).toHaveTextContent(expectedQuestion); + }); + + it("displays external action text without app name when external app name is null", () => { + // Arrange + render( + + + , + ); + const dialogContent = screen.getByTestId("dialog-content"); + + // Assert + const expectedText = msgs.deleteApp.defaultMessage; + const expectedQuestion = msgs.deleteAppQuestion.defaultMessage; + expect(dialogContent).toHaveTextContent(expectedText); + expect(dialogContent).toHaveTextContent(expectedQuestion); + }); + + it("displays custom action text without app name when custom app name is null", () => { + // Arrange + render( + + + , + ); + const dialogContent = screen.getByTestId("dialog-content"); + + // Assert + const expectedText = msgs.deleteLocalApp.defaultMessage; + const expectedQuestion = msgs.deleteAppQuestion.defaultMessage; + expect(dialogContent).toHaveTextContent(expectedText); + expect(dialogContent).toHaveTextContent(expectedQuestion); + }); +}); diff --git a/src/new-apps/components/AppDeleteDialog/AppDeleteDialog.tsx b/src/new-apps/components/AppDeleteDialog/AppDeleteDialog.tsx new file mode 100644 index 000000000..e2c657d33 --- /dev/null +++ b/src/new-apps/components/AppDeleteDialog/AppDeleteDialog.tsx @@ -0,0 +1,65 @@ +import { DialogContentText } from "@material-ui/core"; +import ActionDialog from "@saleor/components/ActionDialog"; +import { ConfirmButtonTransitionState } from "@saleor/macaw-ui"; +import { getStringOrPlaceholder } from "@saleor/misc"; +import React from "react"; +import { FormattedMessage, useIntl } from "react-intl"; + +import msgs from "./messages"; + +export interface AppDeleteDialogProps { + confirmButtonState: ConfirmButtonTransitionState; + open: boolean; + name?: string | null; + onClose: () => void; + onConfirm: () => void; + type: "CUSTOM" | "EXTERNAL"; +} + +const AppDeleteDialog: React.FC = ({ + confirmButtonState, + open, + name, + onClose, + onConfirm, + type, +}) => { + const intl = useIntl(); + + const missingName = name === null || name === ""; + const isExternal = type === "EXTERNAL"; + + const getMainText = () => { + if (missingName && isExternal) { + return intl.formatMessage(msgs.deleteApp); + } + if (missingName) { + return intl.formatMessage(msgs.deleteLocalApp); + } + if (isExternal) { + return intl.formatMessage(msgs.deleteNamedApp, { + name: {getStringOrPlaceholder(name)}, + }); + } + return intl.formatMessage(msgs.deleteLocalNamedApp, { + name: {getStringOrPlaceholder(name)}, + }); + }; + + return ( + + + {getMainText()} + + + ); +}; +AppDeleteDialog.displayName = "AppDeleteDialog"; +export default AppDeleteDialog; diff --git a/src/new-apps/components/AppDeleteDialog/index.ts b/src/new-apps/components/AppDeleteDialog/index.ts new file mode 100644 index 000000000..06d0e0693 --- /dev/null +++ b/src/new-apps/components/AppDeleteDialog/index.ts @@ -0,0 +1,2 @@ +export * from "./AppDeleteDialog"; +export { default } from "./AppDeleteDialog"; diff --git a/src/new-apps/components/AppDeleteDialog/messages.ts b/src/new-apps/components/AppDeleteDialog/messages.ts new file mode 100644 index 000000000..885b0141d --- /dev/null +++ b/src/new-apps/components/AppDeleteDialog/messages.ts @@ -0,0 +1,38 @@ +import { defineMessages } from "react-intl"; + +export default defineMessages({ + deleteAppTitle: { + id: "zQX6xO", + defaultMessage: "Delete App", + description: "dialog header", + }, + deleteApp: { + id: "2rJcFa", + defaultMessage: + "Deleting this app, you will remove installation of the app. If you are paying for app subscription, remember to unsubscribe from the app in Saleor Marketplace.", + description: "delete app", + }, + deleteLocalApp: { + id: "0vsMRq", + defaultMessage: + "Deleting this app, you will delete all the data and webhooks regarding this app.", + description: "delete custom app", + }, + deleteNamedApp: { + id: "kYtxJ1", + defaultMessage: + "Deleting {name}, you will remove installation of the app. If you are paying for app subscription, remember to unsubscribe from the app in Saleor Marketplace.", + description: "delete app", + }, + deleteLocalNamedApp: { + id: "nioOBQ", + defaultMessage: + "Deleting {name}, you will delete all the data and webhooks regarding this app.", + description: "delete custom app", + }, + deleteAppQuestion: { + id: "6hLZNA", + defaultMessage: "Are you sure you want to delete this app?", + description: "delete app", + }, +}); diff --git a/src/new-apps/components/AppListCard/AppListCard.test.tsx b/src/new-apps/components/AppListCard/AppListCard.test.tsx new file mode 100644 index 000000000..fa24d7946 --- /dev/null +++ b/src/new-apps/components/AppListCard/AppListCard.test.tsx @@ -0,0 +1,147 @@ +import { comingSoonApp, releasedApp } from "@saleor/new-apps/fixtures"; +import { GetV2SaleorAppsResponse } from "@saleor/new-apps/marketplace.types"; +import Wrapper from "@test/wrapper"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import React from "react"; + +import AppListCard from "./AppListCard"; + +describe("Apps AppListCard", () => { + it("displays released app details when released app data passed", () => { + // Arrange + const integrationImages = releasedApp.integrations.map( + integration => integration.logo.light.source, + ); + render( + + + , + ); + const name = screen.queryByText(releasedApp.name.en); + const description = screen.queryByText(releasedApp.description.en); + const images = screen.getAllByRole("img"); + const links = screen.getAllByRole("link"); + + // Assert + expect(name).toBeTruthy(); + expect(description).toBeTruthy(); + const expectedImages = [releasedApp.logo.source, ...integrationImages]; + images.forEach(image => + expect(expectedImages).toContain(image.getAttribute("src")), + ); + const expectedLinks = [ + releasedApp.privacyUrl, + releasedApp.repositoryUrl, + releasedApp.supportUrl, + ]; + links.forEach(link => + expect(expectedLinks).toContain(link.getAttribute("href")), + ); + }); + + it("calls handlers when released app data passed and buttons clicked", async () => { + // Arrange + const navigateToAppInstallPage = jest.fn(); + const navigateToVercelDeploymentPage = jest.fn(); + render( + + + , + ); + const user = userEvent.setup(); + const installButton = screen.getByTestId("app-install-button"); + const deployToVercelButton = screen.getByTestId( + "app-deploy-to-vercel-button", + ); + + // Act + await user.click(installButton); + await user.click(deployToVercelButton); + + // Assert + expect(navigateToAppInstallPage).toBeCalledTimes(1); + expect(navigateToVercelDeploymentPage).toBeCalledTimes(1); + }); + + it("displays coming soon app details when coming soon app data passed", () => { + // Arrange + const integrationImages = comingSoonApp.integrations.map( + integration => integration.logo.light.source, + ); + render( + + + , + ); + const name = screen.queryByText(comingSoonApp.name.en); + const description = screen.queryByText(comingSoonApp.description.en); + const images = screen.getAllByRole("img"); + const links = screen.queryAllByRole("link"); + const releaseDate = screen.queryByText(comingSoonApp.releaseDate, { + exact: false, + }); + + // Assert + expect(name).toBeTruthy(); + expect(description).toBeTruthy(); + const expectedImages = [comingSoonApp.logo.source, ...integrationImages]; + images.forEach(image => + expect(expectedImages).toContain(image.getAttribute("src")), + ); + expect(links).toHaveLength(0); + expect(releaseDate).toBeTruthy(); + }); + + it("displays placeholder initial when no released app logo passed", () => { + // Arrange + const app: GetV2SaleorAppsResponse.ReleasedSaleorApp = { + ...releasedApp, + logo: { + ...releasedApp.logo, + source: null, + }, + }; + render( + + + , + ); + const logo = screen.getByTestId("app-logo"); + const logoPlaceholder = within(logo).queryByTestId("app-logo-placeholder"); + const logoImage = within(logo).queryByRole("img"); + + // Assert + expect(logoPlaceholder).toBeTruthy(); + expect(logoPlaceholder?.textContent).toBe(app.name.en[0]); + expect(logoImage).toBeFalsy(); + }); + + it("displays placeholder initial when no coming soon app logo passed", () => { + // Arrange + const app: GetV2SaleorAppsResponse.ComingSoonSaleorApp = { + ...comingSoonApp, + logo: { + ...comingSoonApp.logo, + source: null, + }, + }; + render( + + + , + ); + const logo = screen.getByTestId("app-logo"); + const logoPlaceholder = within(logo).queryByTestId("app-logo-placeholder"); + const logoImage = within(logo).queryByRole("img"); + + // Assert + expect(logoPlaceholder).toBeTruthy(); + expect(logoPlaceholder?.textContent).toBe(app.name.en[0]); + expect(logoImage).toBeFalsy(); + }); +}); diff --git a/src/new-apps/components/AppListCard/AppListCard.tsx b/src/new-apps/components/AppListCard/AppListCard.tsx new file mode 100644 index 000000000..088ddaf0d --- /dev/null +++ b/src/new-apps/components/AppListCard/AppListCard.tsx @@ -0,0 +1,52 @@ +import { Card, CardContent } from "@material-ui/core"; +import { GetV2SaleorAppsResponse } from "@saleor/new-apps/marketplace.types"; +import { getAppDetails } from "@saleor/new-apps/utils"; +import React from "react"; +import { useIntl } from "react-intl"; + +import AppListCardActions from "./AppListCardActions"; +import AppListCardDescription from "./AppListCardDescription"; +import AppListCardIntegrations from "./AppListCardIntegrations"; +import AppListCardLinks from "./AppListCardLinks"; +import { useStyles } from "./styles"; + +interface AppListCardProps { + app: GetV2SaleorAppsResponse.SaleorApp; + navigateToAppInstallPage?: (manifestUrl: string) => void; + navigateToVercelDeploymentPage?: (vercelDeploymentUrl: string) => void; +} + +const AppListCard: React.FC = ({ + app, + navigateToAppInstallPage, + navigateToVercelDeploymentPage, +}) => { + const classes = useStyles(); + const intl = useIntl(); + + const details = getAppDetails( + intl, + app, + navigateToAppInstallPage, + navigateToVercelDeploymentPage, + ); + + return ( + <> + + + + + + + + + + ); +}; +AppListCard.displayName = "AppListCard"; +export default AppListCard; diff --git a/src/new-apps/components/AppListCard/AppListCardActions.tsx b/src/new-apps/components/AppListCard/AppListCardActions.tsx new file mode 100644 index 000000000..c078edf0d --- /dev/null +++ b/src/new-apps/components/AppListCard/AppListCardActions.tsx @@ -0,0 +1,65 @@ +import { CardActions, Typography } from "@material-ui/core"; +import { Button } from "@saleor/components/Button"; +import Hr from "@saleor/components/Hr"; +import { buttonMessages } from "@saleor/intl"; +import React from "react"; +import { FormattedMessage } from "react-intl"; + +import { messages } from "./messages"; +import { useActionsStyles } from "./styles"; + +interface AppListCardActionsProps { + releaseDate: string | undefined; + installHandler?: () => void; + vercelDeployHandler?: () => void; +} + +const AppListCardActions: React.FC = ({ + releaseDate, + installHandler, + vercelDeployHandler, +}) => { + const classes = useActionsStyles(); + + if (!installHandler && !vercelDeployHandler && !releaseDate) { + return null; + } + + return ( + <> +
+ + {vercelDeployHandler && ( + + )} + {installHandler && ( + + )} + {releaseDate && ( + + + + )} + + + ); +}; +AppListCardActions.displayName = "AppListCardActions"; +export default AppListCardActions; diff --git a/src/new-apps/components/AppListCard/AppListCardDescription.tsx b/src/new-apps/components/AppListCard/AppListCardDescription.tsx new file mode 100644 index 000000000..34ea5c8b7 --- /dev/null +++ b/src/new-apps/components/AppListCard/AppListCardDescription.tsx @@ -0,0 +1,44 @@ +import { Typography } from "@material-ui/core"; +import { GetV2SaleorAppsResponse } from "@saleor/new-apps/marketplace.types"; +import React from "react"; + +import { useDescriptionStyles } from "./styles"; + +interface AppListCardDescriptionProps { + app: GetV2SaleorAppsResponse.SaleorApp; +} + +const AppListCardDescription: React.FC = ({ + app, +}) => { + const classes = useDescriptionStyles(); + + return ( + <> +
+
+ {app.logo.source && App logo} + {!app.logo.source && ( + + {app.name.en[0] || ""} + + )} +
+ + {app.name.en} + +
+ + {app.description.en} + + + ); +}; +AppListCardDescription.displayName = "AppListCardDescription"; +export default AppListCardDescription; diff --git a/src/new-apps/components/AppListCard/AppListCardIntegrations.tsx b/src/new-apps/components/AppListCard/AppListCardIntegrations.tsx new file mode 100644 index 000000000..554207645 --- /dev/null +++ b/src/new-apps/components/AppListCard/AppListCardIntegrations.tsx @@ -0,0 +1,48 @@ +import { Typography } from "@material-ui/core"; +import { useTheme } from "@saleor/macaw-ui"; +import { GetV2SaleorAppsResponse } from "@saleor/new-apps/marketplace.types"; +import React from "react"; +import { FormattedMessage } from "react-intl"; + +import { messages } from "./messages"; +import { useIntegrationsStyles } from "./styles"; + +interface AppListCardIntegrationsProps { + app: GetV2SaleorAppsResponse.SaleorApp; +} + +const AppListCardIntegrations: React.FC = ({ + app, +}) => { + const { themeType } = useTheme(); + const classes = useIntegrationsStyles(); + + if (!app.integrations.length) { + return null; + } + + return ( + <> + + + +
    + {app.integrations.map(integration => ( +
  • + {integration.name} +
  • + ))} +
+ + ); +}; +AppListCardIntegrations.displayName = "AppListCardIntegrations"; +export default AppListCardIntegrations; diff --git a/src/new-apps/components/AppListCard/AppListCardLinks.tsx b/src/new-apps/components/AppListCard/AppListCardLinks.tsx new file mode 100644 index 000000000..438013484 --- /dev/null +++ b/src/new-apps/components/AppListCard/AppListCardLinks.tsx @@ -0,0 +1,34 @@ +import { Typography } from "@material-ui/core"; +import Link from "@saleor/components/Link"; +import { AppLink } from "@saleor/new-apps/types"; +import React from "react"; + +import { useLinksStyles } from "./styles"; + +interface AppListCardLinksProps { + links: AppLink[]; +} + +const AppListCardLinks: React.FC = ({ links }) => { + const classes = useLinksStyles(); + + if (!links.length) { + return null; + } + + return ( +
    + {links.map(link => ( +
  • + + + {link.name} + + +
  • + ))} +
+ ); +}; +AppListCardLinks.displayName = "AppListCardLinks"; +export default AppListCardLinks; diff --git a/src/new-apps/components/AppListCard/index.ts b/src/new-apps/components/AppListCard/index.ts new file mode 100644 index 000000000..1153ae4e7 --- /dev/null +++ b/src/new-apps/components/AppListCard/index.ts @@ -0,0 +1,2 @@ +export * from "./AppListCard"; +export { default } from "./AppListCard"; diff --git a/src/new-apps/components/AppListCard/messages.ts b/src/new-apps/components/AppListCard/messages.ts new file mode 100644 index 000000000..81fbc9b67 --- /dev/null +++ b/src/new-apps/components/AppListCard/messages.ts @@ -0,0 +1,19 @@ +import { defineMessages } from "react-intl"; + +export const messages = defineMessages({ + integrations: { + id: "MaTR88", + defaultMessage: "Integrations", + description: "header", + }, + deployToVercel: { + id: "ohQrzn", + defaultMessage: "Deploy to Vercel", + description: "button", + }, + releaseComingSoon: { + id: "oGIPb7", + defaultMessage: "Coming soon ({releaseDate})", + description: "information", + }, +}); diff --git a/src/new-apps/components/AppListCard/styles.ts b/src/new-apps/components/AppListCard/styles.ts new file mode 100644 index 000000000..06e1cc7e5 --- /dev/null +++ b/src/new-apps/components/AppListCard/styles.ts @@ -0,0 +1,111 @@ +import { alpha } from "@material-ui/core"; +import { makeStyles } from "@saleor/macaw-ui"; + +export const useStyles = makeStyles( + () => ({ + card: { + display: "flex", + flexDirection: "column", + }, + cardContent: { + height: "100%", + }, + }), + { name: "AppListCard" }, +); + +export const useDescriptionStyles = makeStyles( + theme => ({ + cardHeader: { + fontWeight: 600, + fontSize: 20, + fontFamily: "sans-serif", + }, + cardToolbar: { + display: "flex", + alignItems: "center", + }, + logo: { + color: "#fff", + width: 50, + overflow: "hidden", + height: 50, + marginRight: 20, + borderRadius: 10, + display: "flex", + alignItems: "center", + justifyContent: "center", + "& img": { + maxWidth: "100%", + maxHeight: "100%", + }, + }, + description: { + color: theme.palette.saleor.main[3], + margin: theme.spacing(3, 0, 1, 0), + }, + }), + { + name: "AppListCardDescription", + }, +); + +export const useLinksStyles = makeStyles( + theme => ({ + linkList: { + display: "flex", + gap: 10, + margin: theme.spacing(1, 0, 4, 0), + padding: 0, + listStyleType: "none", + }, + }), + { name: "AppListCardLinks" }, +); + +export const useIntegrationsStyles = makeStyles( + theme => ({ + listHeader: { + fontSize: 14, + fontWeight: 700, + color: theme.palette.saleor.main[3], + margin: theme.spacing(2, 0), + textTransform: "uppercase", + }, + logoList: { + display: "flex", + gap: theme.spacing(2), + margin: 0, + padding: 0, + }, + vendorLogo: { + display: "flex", + alignItems: "center", + justifyContent: "center", + "& img": { + maxWidth: "100%", + maxHeight: "100%", + }, + background: + theme.palette.type === "light" + ? "transparent" + : alpha(theme.palette.saleor.generic.light, 0.3), + }, + }), + { name: "AppListCardIntegrations" }, +); + +export const useActionsStyles = makeStyles( + theme => ({ + cardActions: { + display: "flex", + justifyContent: "flex-end", + padding: theme.spacing(2, 4), + minHeight: theme.spacing(9), + }, + releaseDate: { + color: theme.palette.saleor.main[3], + }, + }), + { name: "AppListCardActions" }, +); diff --git a/src/new-apps/components/AppListPage/AppListPage.stories.tsx b/src/new-apps/components/AppListPage/AppListPage.stories.tsx new file mode 100644 index 000000000..d2ce42e5a --- /dev/null +++ b/src/new-apps/components/AppListPage/AppListPage.stories.tsx @@ -0,0 +1,23 @@ +import { installedAppsList } from "@saleor/new-apps/fixtures"; +import Decorator from "@saleor/storybook/Decorator"; +import { storiesOf } from "@storybook/react"; +import React from "react"; + +import AppListPage, { AppListPageProps } from "./AppListPage"; + +const props: AppListPageProps = { + disabled: false, + installedApps: installedAppsList, +}; + +storiesOf("Views / New Apps / App List", module) + .addDecorator(Decorator) + .add("default", () => ) + .add("empty", () => ( + + )); diff --git a/src/new-apps/components/AppListPage/AppListPage.tsx b/src/new-apps/components/AppListPage/AppListPage.tsx new file mode 100644 index 000000000..a39f7a534 --- /dev/null +++ b/src/new-apps/components/AppListPage/AppListPage.tsx @@ -0,0 +1,113 @@ +import { Typography } from "@material-ui/core"; +import Container from "@saleor/components/Container"; +import PageHeader from "@saleor/components/PageHeader"; +import useNavigator from "@saleor/hooks/useNavigator"; +import { sectionNames } from "@saleor/intl"; +import { AppUrls } from "@saleor/new-apps/urls"; +import { ListProps } from "@saleor/types"; +import React, { useCallback } from "react"; +import { FormattedMessage, useIntl } from "react-intl"; + +import AllAppList from "../AllAppList"; +import InstalledAppList from "../InstalledAppList"; +import { InstallWithManifestFormButton } from "../InstallWithManifestFormButton"; +import MarketplaceAlert from "../MarketplaceAlert"; +import SectionHeader from "../SectionHeader"; +import { messages } from "./messages"; +import { useStyles } from "./styles"; +import { AppListPageSections } from "./types"; +import { + getVerifiedInstallableMarketplaceApps, + getVerifiedInstalledApps, + resolveSectionsAvailability, +} from "./utils"; + +export interface AppListPageProps extends AppListPageSections, ListProps { + marketplaceError?: Error; +} + +export const AppListPage: React.FC = props => { + const { + installedApps, + installableMarketplaceApps, + comingSoonMarketplaceApps, + disabled, + settings, + marketplaceError, + onUpdateListSettings, + } = props; + const intl = useIntl(); + const classes = useStyles(); + const verifiedInstalledApps = getVerifiedInstalledApps( + installedApps, + installableMarketplaceApps, + ); + const verifiedInstallableMarketplaceApps = getVerifiedInstallableMarketplaceApps( + installedApps, + installableMarketplaceApps, + ); + const sectionsAvailability = resolveSectionsAvailability({ + ...props, + installableMarketplaceApps: verifiedInstallableMarketplaceApps, + }); + const navigate = useNavigator(); + + const navigateToAppInstallPage = useCallback( + (manifestUrl: string) => { + navigate(AppUrls.resolveAppInstallUrl(manifestUrl)); + }, + [navigate], + ); + + const navigateToVercelDeploymentPage = useCallback( + (vercelDeploymentUrl: string) => { + window.open(vercelDeploymentUrl, "_blank"); + }, + [], + ); + + return ( + + + + + + + +
+ {sectionsAvailability.installed && ( + <> + + + + )} + + {sectionsAvailability.all && !marketplaceError && ( + <> + + + + )} + {sectionsAvailability.comingSoon && !marketplaceError && ( + <> + + + + )} +
+
+ ); +}; +AppListPage.displayName = "AppListPage"; +export default AppListPage; diff --git a/src/new-apps/components/AppListPage/index.ts b/src/new-apps/components/AppListPage/index.ts new file mode 100644 index 000000000..6473423ec --- /dev/null +++ b/src/new-apps/components/AppListPage/index.ts @@ -0,0 +1,2 @@ +export * from "./AppListPage"; +export { default } from "./AppListPage"; diff --git a/src/new-apps/components/AppListPage/messages.ts b/src/new-apps/components/AppListPage/messages.ts new file mode 100644 index 000000000..3871349c0 --- /dev/null +++ b/src/new-apps/components/AppListPage/messages.ts @@ -0,0 +1,25 @@ +import { defineMessages } from "react-intl"; + +export const messages = defineMessages({ + installAppDescription: { + id: "ARfw53", + defaultMessage: + "Install apps created by Saleor team or install external apps.", + description: "description", + }, + installedApps: { + id: "/PhkXi", + defaultMessage: "Installed Apps", + description: "section header", + }, + allApps: { + id: "yZB41s", + defaultMessage: "All Apps", + description: "section header", + }, + comingSoonApps: { + id: "VTq2gn", + defaultMessage: "Coming Soon", + description: "section header", + }, +}); diff --git a/src/new-apps/components/AppListPage/styles.ts b/src/new-apps/components/AppListPage/styles.ts new file mode 100644 index 000000000..24414836a --- /dev/null +++ b/src/new-apps/components/AppListPage/styles.ts @@ -0,0 +1,17 @@ +import { makeStyles } from "@saleor/macaw-ui"; + +export const useStyles = makeStyles( + theme => ({ + appContent: { + margin: theme.spacing(4, 0), + }, + sectionHeader: { + fontSize: 14, + fontWeight: 700, + color: theme.palette.saleor.main[3], + margin: theme.spacing(8, 0, 3, 0), + textTransform: "uppercase", + }, + }), + { name: "AppListPage" }, +); diff --git a/src/new-apps/components/AppListPage/types.ts b/src/new-apps/components/AppListPage/types.ts new file mode 100644 index 000000000..5ad39e6d0 --- /dev/null +++ b/src/new-apps/components/AppListPage/types.ts @@ -0,0 +1,8 @@ +import { AppListItemFragment } from "@saleor/graphql"; +import { GetV2SaleorAppsResponse } from "@saleor/new-apps/marketplace.types"; + +export interface AppListPageSections { + installedApps?: AppListItemFragment[]; + installableMarketplaceApps?: GetV2SaleorAppsResponse.ReleasedSaleorApp[]; + comingSoonMarketplaceApps?: GetV2SaleorAppsResponse.ComingSoonSaleorApp[]; +} diff --git a/src/new-apps/components/AppListPage/utils.test.ts b/src/new-apps/components/AppListPage/utils.test.ts new file mode 100644 index 000000000..7d6dc2ffa --- /dev/null +++ b/src/new-apps/components/AppListPage/utils.test.ts @@ -0,0 +1,244 @@ +import { + AppListItemFragment, + AppTypeEnum, + PermissionEnum, +} from "@saleor/graphql"; +import { + comingSoonApp, + installedAppsList, + releasedApp, +} from "@saleor/new-apps/fixtures"; + +import { AppListPageSections } from "./types"; +import { + getVerifiedInstallableMarketplaceApps, + getVerifiedInstalledApps, + resolveSectionsAvailability, +} from "./utils"; + +describe("App List available sections util", () => { + it("should return available app list sections when all data are passed", () => { + // Arrange + const appSectionsData: AppListPageSections = { + installedApps: installedAppsList, + installableMarketplaceApps: [releasedApp], + comingSoonMarketplaceApps: [comingSoonApp], + }; + + // Act + const sectionsAvailability = resolveSectionsAvailability(appSectionsData); + + // Assert + const expectedSectionsAvailability = { + installed: true, + all: true, + comingSoon: true, + }; + expect(sectionsAvailability).toEqual(expectedSectionsAvailability); + }); + + it("should return no available app list sections when no data are passed", () => { + // Arrange + const appSectionsData: AppListPageSections = { + installedApps: [], + installableMarketplaceApps: [], + comingSoonMarketplaceApps: [], + }; + + // Act + const sectionsAvailability = resolveSectionsAvailability(appSectionsData); + + // Assert + const expectedSectionsAvailability = { + installed: false, + all: false, + comingSoon: false, + }; + expect(sectionsAvailability).toEqual(expectedSectionsAvailability); + }); +}); + +describe("App List verified installed apps util", () => { + it("should return installed apps list labeled as external properly when some of them are external", () => { + // Arrange + const installedApps: AppListItemFragment[] = [ + { + __typename: "App", + id: "QXBjPgE3Ng==", + isActive: true, + name: "app external", + type: AppTypeEnum.THIRDPARTY, + version: "1.0.0", + appUrl: null, + manifestUrl: "https://www.example.com/manifest", + permissions: [ + { + __typename: "Permission", + code: PermissionEnum.MANAGE_USERS, + name: "Manage customers.", + }, + ], + }, + { + __typename: "App", + id: "QXBwOjE3Ng==", + isActive: false, + name: "app local", + type: AppTypeEnum.THIRDPARTY, + version: "1.0.0", + appUrl: "http://localhost:3000", + manifestUrl: "http://localhost:3000/api/manifest", + permissions: [ + { + __typename: "Permission", + code: PermissionEnum.MANAGE_ORDERS, + name: "Manage orders.", + }, + { + __typename: "Permission", + code: PermissionEnum.MANAGE_USERS, + name: "Manage customers.", + }, + ], + }, + ]; + const installableMarketplaceApps = [ + { + name: { + en: "Test app", + }, + description: { + en: "Test app description", + }, + logo: { + source: "https://www.example.com/logo", + color: "#000000", + }, + integrations: [], + manifestUrl: "https://www.example.com/manifest", + privacyUrl: "https://www.example.com/privacy", + supportUrl: "https://www.example.com/support", + repositoryUrl: "https://www.example.com/repository", + vercelDeploymentUrl: "https://www.example.com/deployment", + }, + ]; + + // Act + const verifiedInstalledApps = getVerifiedInstalledApps( + installedApps, + installableMarketplaceApps, + ); + + // Assert + const expectedVerifiedInstalledApps = [ + { + app: installedApps[0], + isExternal: false, + }, + { + app: installedApps[1], + isExternal: true, + }, + ]; + expect(verifiedInstalledApps).toEqual(expectedVerifiedInstalledApps); + }); +}); + +describe("App List verified installable marketplace apps util", () => { + it("should return filtered installable marketplace apps list when some of them are already installed", () => { + // Arrange + const installedApps: AppListItemFragment[] = [ + { + __typename: "App", + id: "QXBjPgE3Ng==", + isActive: true, + name: "app external", + type: AppTypeEnum.THIRDPARTY, + version: "1.0.0", + appUrl: null, + manifestUrl: "https://www.example.com/manifest", + permissions: [ + { + __typename: "Permission", + code: PermissionEnum.MANAGE_USERS, + name: "Manage customers.", + }, + ], + }, + { + __typename: "App", + id: "QXBwOjE3Ng==", + isActive: false, + name: "app local", + type: AppTypeEnum.THIRDPARTY, + version: "1.0.0", + appUrl: "http://localhost:3000", + manifestUrl: "http://localhost:3000/api/manifest", + permissions: [ + { + __typename: "Permission", + code: PermissionEnum.MANAGE_ORDERS, + name: "Manage orders.", + }, + { + __typename: "Permission", + code: PermissionEnum.MANAGE_USERS, + name: "Manage customers.", + }, + ], + }, + ]; + const installableMarketplaceApps = [ + { + name: { + en: "Test app", + }, + description: { + en: "Test app description", + }, + logo: { + source: "https://www.example.com/logo", + color: "#000000", + }, + integrations: [], + manifestUrl: "https://www.example.com/manifest", + privacyUrl: "https://www.example.com/privacy", + supportUrl: "https://www.example.com/support", + repositoryUrl: "https://www.example.com/repository", + vercelDeploymentUrl: "https://www.example.com/deployment", + }, + { + name: { + en: "Test app", + }, + description: { + en: "Test app description", + }, + logo: { + source: "https://www.example-2.com/logo", + color: "#000000", + }, + integrations: [], + manifestUrl: "https://www.example-2.com/manifest", + privacyUrl: "https://www.example-2.com/privacy", + supportUrl: "https://www.example-2.com/support", + repositoryUrl: "https://www.example-2.com/repository", + vercelDeploymentUrl: "https://www.example-2.com/deployment", + }, + ]; + + // Act + const verifiedInstallableMarketplaceApps = getVerifiedInstallableMarketplaceApps( + installedApps, + installableMarketplaceApps, + ); + + // Assert + const expectedVerifiedInstallableMarketplaceApps = [ + installableMarketplaceApps[1], + ]; + expect(verifiedInstallableMarketplaceApps).toEqual( + expectedVerifiedInstallableMarketplaceApps, + ); + }); +}); diff --git a/src/new-apps/components/AppListPage/utils.ts b/src/new-apps/components/AppListPage/utils.ts new file mode 100644 index 000000000..3ae9ac1e6 --- /dev/null +++ b/src/new-apps/components/AppListPage/utils.ts @@ -0,0 +1,49 @@ +import { AppListItemFragment } from "@saleor/graphql"; +import { GetV2SaleorAppsResponse } from "@saleor/new-apps/marketplace.types"; +import { InstalledApp } from "@saleor/new-apps/types"; + +import { AppListPageSections } from "./types"; + +export const resolveSectionsAvailability = ({ + installedApps, + installableMarketplaceApps, + comingSoonMarketplaceApps, +}: AppListPageSections) => ({ + installed: !installedApps || !!installedApps.length, + all: !installableMarketplaceApps || !!installableMarketplaceApps.length, + comingSoon: !comingSoonMarketplaceApps || !!comingSoonMarketplaceApps.length, +}); + +const isAppExternal = ( + manifestUrl: string | null, + installableMarketplaceApps?: GetV2SaleorAppsResponse.ReleasedSaleorApp[], +) => + !!manifestUrl && + !!installableMarketplaceApps?.every(app => app.manifestUrl !== manifestUrl); + +export const getVerifiedInstalledApps = ( + installedApps?: AppListItemFragment[], + installableMarketplaceApps?: GetV2SaleorAppsResponse.ReleasedSaleorApp[], +): InstalledApp[] | undefined => + installedApps?.map(app => ({ + app, + isExternal: isAppExternal(app.manifestUrl, installableMarketplaceApps), + })); + +/* + * Temporary solution for checking if app is not installed. + * Installed app list is paginated, it needs to be replace in the future with proper solution, + * not relying on one page of installed apps list. + */ +const isAppNotInstalled = ( + manifestUrl: string, + installedApps?: AppListItemFragment[], +) => installedApps?.every(app => app.manifestUrl !== manifestUrl); + +export const getVerifiedInstallableMarketplaceApps = ( + installedApps?: AppListItemFragment[], + installableMarketplaceApps?: GetV2SaleorAppsResponse.ReleasedSaleorApp[], +): GetV2SaleorAppsResponse.ReleasedSaleorApp[] | undefined => + installableMarketplaceApps?.filter(app => + isAppNotInstalled(app.manifestUrl, installedApps), + ); diff --git a/src/new-apps/components/AppManifestTableDisplay/AppManifestTableDisplay.tsx b/src/new-apps/components/AppManifestTableDisplay/AppManifestTableDisplay.tsx new file mode 100644 index 000000000..988752a1e --- /dev/null +++ b/src/new-apps/components/AppManifestTableDisplay/AppManifestTableDisplay.tsx @@ -0,0 +1,57 @@ +import { Typography } from "@material-ui/core"; +import { CopyIcon, Tooltip } from "@saleor/macaw-ui"; +import { appsMessages } from "@saleor/new-apps/messages"; +import clsx from "clsx"; +import React, { useState } from "react"; +import { useIntl } from "react-intl"; + +import { useStyles } from "./styles"; + +interface AppManifestTableDisplayProps { + manifestUrl: string; +} + +const getAppDomainFromManifest = (manifest: string) => new URL(manifest).host; + +export const AppManifestTableDisplay = ({ + manifestUrl, +}: AppManifestTableDisplayProps) => { + const styles = useStyles(); + const intl = useIntl(); + 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 + } + }} + > + {getAppDomainFromManifest(manifestUrl)} + {!!navigator.clipboard && ( + + )} + + + ); +}; +AppManifestTableDisplay.displayName = "AppManifestTableDisplay"; +export default AppManifestTableDisplay; diff --git a/src/new-apps/components/AppManifestTableDisplay/index.ts b/src/new-apps/components/AppManifestTableDisplay/index.ts new file mode 100644 index 000000000..0e84eb1bb --- /dev/null +++ b/src/new-apps/components/AppManifestTableDisplay/index.ts @@ -0,0 +1,2 @@ +export { AppManifestTableDisplay } from "./AppManifestTableDisplay"; +export { default } from "./AppManifestTableDisplay"; diff --git a/src/new-apps/components/AppManifestTableDisplay/styles.ts b/src/new-apps/components/AppManifestTableDisplay/styles.ts new file mode 100644 index 000000000..b0d1a745b --- /dev/null +++ b/src/new-apps/components/AppManifestTableDisplay/styles.ts @@ -0,0 +1,28 @@ +import { makeStyles } from "@saleor/macaw-ui"; + +export 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: { + marginLeft: theme.spacing(1), + visibility: "hidden", + verticalAlign: "middle", + transition: "0.2s", + cursor: "pointer", + }, + copyIconColorful: { + color: theme.palette.primary.main, + animation: "$pulse 0.2s", + }, + }), + { name: "AppManifestTableDisplay" }, +); diff --git a/src/new-apps/components/AppPermissions/AppPermissions.tsx b/src/new-apps/components/AppPermissions/AppPermissions.tsx new file mode 100644 index 000000000..b571c3a43 --- /dev/null +++ b/src/new-apps/components/AppPermissions/AppPermissions.tsx @@ -0,0 +1,36 @@ +import { AppPermissionFragment } from "@saleor/graphql"; +import { IconButton, PermissionsIcon, Tooltip } from "@saleor/macaw-ui"; +import React from "react"; +import { FormattedMessage } from "react-intl"; + +import { messages } from "./messages"; +import { useStyles } from "./styles"; + +interface AppPermissionsProps { + permissions?: AppPermissionFragment[] | null; +} + +export const AppPermissions: React.FC = ({ + permissions, +}) => { + const classes = useStyles(); + + return ( + } + title={ +
    + {permissions?.map(permission => ( +
  • {permission.name}
  • + ))} +
+ } + > + + + +
+ ); +}; +AppPermissions.displayName = "AppPermissions"; +export default AppPermissions; diff --git a/src/new-apps/components/AppPermissions/index.ts b/src/new-apps/components/AppPermissions/index.ts new file mode 100644 index 000000000..37c02d998 --- /dev/null +++ b/src/new-apps/components/AppPermissions/index.ts @@ -0,0 +1,2 @@ +export { AppPermissions } from "./AppPermissions"; +export { default } from "./AppPermissions"; diff --git a/src/new-apps/components/AppPermissions/messages.ts b/src/new-apps/components/AppPermissions/messages.ts new file mode 100644 index 000000000..8055ca41f --- /dev/null +++ b/src/new-apps/components/AppPermissions/messages.ts @@ -0,0 +1,9 @@ +import { defineMessages } from "react-intl"; + +export const messages = defineMessages({ + appPermissions: { + id: "xNfh4L", + defaultMessage: "App permissions", + description: "app permissions tooltip header", + }, +}); diff --git a/src/new-apps/components/AppPermissions/styles.ts b/src/new-apps/components/AppPermissions/styles.ts new file mode 100644 index 000000000..a160ebb25 --- /dev/null +++ b/src/new-apps/components/AppPermissions/styles.ts @@ -0,0 +1,11 @@ +import { makeStyles } from "@saleor/macaw-ui"; + +export const useStyles = makeStyles( + () => ({ + list: { + margin: 0, + paddingLeft: "16px", + }, + }), + { name: "AppPermissions" }, +); diff --git a/src/new-apps/components/InstallWithManifestFormButton/InstallWithManifestFormButton.test.tsx b/src/new-apps/components/InstallWithManifestFormButton/InstallWithManifestFormButton.test.tsx new file mode 100644 index 000000000..fbd9a087b --- /dev/null +++ b/src/new-apps/components/InstallWithManifestFormButton/InstallWithManifestFormButton.test.tsx @@ -0,0 +1,65 @@ +import Wrapper from "@test/wrapper"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import React from "react"; + +import InstallWithManifestFormButton from "./InstallWithManifestFormButton"; + +describe("Apps InstallWithManifestFormButton", () => { + it("submit form when valid manifest url value passed", async () => { + // Arrange + const submitHandler = jest.fn(); + render( + + + , + ); + const user = userEvent.setup(); + const installButton = screen.getAllByTestId("add-app-from-manifest")[0]; + // Act + await user.click(installButton); + // Arrange + const input = screen.getByRole("textbox"); + // Assert + expect(input).not.toHaveValue(); + // Act + await user.type(input, "https://example.com/manifest.json"); + // Assert + expect(input).toHaveValue("https://example.com/manifest.json"); + // Arrange + const submitButton = screen.getAllByTestId("install-app-from-manifest")[0]; + // Act + await user.click(submitButton); + // Assert + expect(submitHandler).toBeCalledTimes(1); + expect(submitHandler).toBeCalledWith("https://example.com/manifest.json"); + }); + + it("return error when invalid manifest url value passed", async () => { + // Arrange + const submitHandler = jest.fn(); + render( + + + , + ); + const user = userEvent.setup(); + const installButton = screen.getAllByTestId("add-app-from-manifest")[0]; + // Act + await user.click(installButton); + // Arrange + const input = screen.getByRole("textbox"); + // Assert + expect(input).not.toHaveValue(); + // Act + await user.type(input, "example wrong url"); + // Assert + expect(input).toHaveValue("example wrong url"); + // Arrange + const submitButton = screen.getAllByTestId("install-app-from-manifest")[0]; + // Act + await user.click(submitButton); + // Assert + expect(submitHandler).toBeCalledTimes(0); + }); +}); diff --git a/src/new-apps/components/InstallWithManifestFormButton/InstallWithManifestFormButton.tsx b/src/new-apps/components/InstallWithManifestFormButton/InstallWithManifestFormButton.tsx new file mode 100644 index 000000000..b95013100 --- /dev/null +++ b/src/new-apps/components/InstallWithManifestFormButton/InstallWithManifestFormButton.tsx @@ -0,0 +1,74 @@ +import { TextField } from "@material-ui/core"; +import { Button } from "@saleor/components/Button"; +import { buttonMessages } from "@saleor/intl"; +import { appsMessages } from "@saleor/new-apps/messages"; +import React, { useState } from "react"; +import { FormattedMessage, useIntl } from "react-intl"; + +import { messages } from "./messages"; +import { useStyles } from "./styles"; + +interface Props { + onSubmitted(manifestUrl: string): void; +} + +export const InstallWithManifestFormButton: React.FC = ({ + onSubmitted, +}) => { + const styles = useStyles(); + const intl = useIntl(); + + const [inputOpened, setInputOpened] = useState(false); + + const handleFormSubmit: React.FormEventHandler = e => { + e.preventDefault(); + + const form = new FormData(e.currentTarget); + const inputValue = form.get("manifest-url") as string; + + try { + new URL(inputValue); + + onSubmitted(inputValue); + } catch (e) { + console.error("Invalid URL from input. Should be validated by browser"); + } + }; + + if (inputOpened) { + return ( +
+ + + + ); + } + + return ( + + ); +}; +InstallWithManifestFormButton.displayName = "InstallWithManifestFormButton"; +export default InstallWithManifestFormButton; diff --git a/src/new-apps/components/InstallWithManifestFormButton/index.ts b/src/new-apps/components/InstallWithManifestFormButton/index.ts new file mode 100644 index 000000000..6d7d08bd9 --- /dev/null +++ b/src/new-apps/components/InstallWithManifestFormButton/index.ts @@ -0,0 +1,2 @@ +export * from "./InstallWithManifestFormButton"; +export { default } from "./InstallWithManifestFormButton"; diff --git a/src/new-apps/components/InstallWithManifestFormButton/messages.ts b/src/new-apps/components/InstallWithManifestFormButton/messages.ts new file mode 100644 index 000000000..ed03f86cf --- /dev/null +++ b/src/new-apps/components/InstallWithManifestFormButton/messages.ts @@ -0,0 +1,14 @@ +import { defineMessages } from "react-intl"; + +export const messages = defineMessages({ + installExternalApp: { + id: "puUvKO", + defaultMessage: "Install external app", + description: "button", + }, + appManifestUrlHint: { + id: "z2yYL3", + defaultMessage: "Usually ends with /api/manifest", + description: "hint", + }, +}); diff --git a/src/new-apps/components/InstallWithManifestFormButton/styles.ts b/src/new-apps/components/InstallWithManifestFormButton/styles.ts new file mode 100644 index 000000000..80c8b23ab --- /dev/null +++ b/src/new-apps/components/InstallWithManifestFormButton/styles.ts @@ -0,0 +1,13 @@ +import { makeStyles } from "@saleor/macaw-ui"; + +export const useStyles = makeStyles( + theme => ({ + installButton: { + marginLeft: theme.spacing(2), + height: 52, + }, + }), + { + name: "InstallWithManifestFormButton", + }, +); diff --git a/src/new-apps/components/InstalledAppList/InstalledAppList.tsx b/src/new-apps/components/InstalledAppList/InstalledAppList.tsx new file mode 100644 index 000000000..6c9092558 --- /dev/null +++ b/src/new-apps/components/InstalledAppList/InstalledAppList.tsx @@ -0,0 +1,47 @@ +import { Table, TableBody, TableFooter, TableRow } from "@material-ui/core"; +import { Skeleton } from "@material-ui/lab"; +import { TablePaginationWithContext } from "@saleor/components/TablePagination"; +import { InstalledApp } from "@saleor/new-apps/types"; +import { ListProps } from "@saleor/types"; +import React from "react"; + +import InstalledAppListRow from "../InstalledAppListRow"; +import { useStyles } from "./styles"; + +interface InstalledAppListProps extends ListProps { + appList?: InstalledApp[]; +} + +const InstalledAppList: React.FC = ({ + appList, + disabled, + settings, + onUpdateListSettings, +}) => { + const classes = useStyles(); + + if (!appList) { + return ; + } + + return ( + + + {appList.map(({ app, isExternal }) => ( + + ))} + + + + + + +
+ ); +}; + +export default InstalledAppList; diff --git a/src/new-apps/components/InstalledAppList/index.ts b/src/new-apps/components/InstalledAppList/index.ts new file mode 100644 index 000000000..64a32f858 --- /dev/null +++ b/src/new-apps/components/InstalledAppList/index.ts @@ -0,0 +1,2 @@ +export * from "./InstalledAppList"; +export { default } from "./InstalledAppList"; diff --git a/src/new-apps/components/InstalledAppList/messages.ts b/src/new-apps/components/InstalledAppList/messages.ts new file mode 100644 index 000000000..c3ef5fc9e --- /dev/null +++ b/src/new-apps/components/InstalledAppList/messages.ts @@ -0,0 +1,14 @@ +import { defineMessages } from "react-intl"; + +export const messages = defineMessages({ + externalApp: { + id: "nA2g2e", + defaultMessage: "External app", + description: "label", + }, + tunnelDevelopment: { + id: "RjT+Vo", + defaultMessage: "Tunnel - development", + description: "label", + }, +}); diff --git a/src/new-apps/components/InstalledAppList/styles.ts b/src/new-apps/components/InstalledAppList/styles.ts new file mode 100644 index 000000000..c3b5b4977 --- /dev/null +++ b/src/new-apps/components/InstalledAppList/styles.ts @@ -0,0 +1,10 @@ +import { makeStyles } from "@saleor/macaw-ui"; + +export const useStyles = makeStyles( + theme => ({ + table: { + borderTop: `1px solid ${theme.palette.divider}`, + }, + }), + { name: "InstalledAppList" }, +); diff --git a/src/new-apps/components/InstalledAppListRow/InstalledAppListRow.test.tsx b/src/new-apps/components/InstalledAppListRow/InstalledAppListRow.test.tsx new file mode 100644 index 000000000..789163825 --- /dev/null +++ b/src/new-apps/components/InstalledAppListRow/InstalledAppListRow.test.tsx @@ -0,0 +1,196 @@ +import { getAppsConfig } from "@saleor/config"; +import { AppListContext, AppListContextValues } from "@saleor/new-apps/context"; +import { activeApp, inactiveApp } from "@saleor/new-apps/fixtures"; +import { InstalledApp } from "@saleor/new-apps/types"; +import Wrapper from "@test/wrapper"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import React from "react"; +import { MemoryRouter as Router } from "react-router-dom"; + +import InstalledAppListRow from "./InstalledAppListRow"; + +const Component = ({ + data, + context, +}: { + data: InstalledApp; + context: AppListContextValues; +}) => ( + + + + + + + +); + +describe("Apps InstalledAppListRow", () => { + it("displays app details when basic app data passed", () => { + // Arrange + const activateApp = jest.fn(); + const deactivateApp = jest.fn(); + const removeApp = jest.fn(); + render( + , + ); + const name = screen.queryByText(activeApp.name as string); + const version = screen.queryByText(activeApp.version as string, { + exact: false, + }); + const manifestDomain = screen.queryByText( + new URL(activeApp.manifestUrl as string).host, + ); + const externalLabel = screen.queryByTestId("app-external-label"); + const tunnelLabel = screen.queryByTestId("app-tunnel-label"); + + // Assert + expect(name).toBeTruthy(); + expect(version).toBeTruthy(); + expect(manifestDomain).toBeTruthy(); + expect(externalLabel).toBeFalsy(); + expect(tunnelLabel).toBeFalsy(); + }); + + it("displays external label when app is external", () => { + // Arrange + const activateApp = jest.fn(); + const deactivateApp = jest.fn(); + const removeApp = jest.fn(); + render( + , + ); + const externalLabel = screen.queryByTestId("app-external-label"); + + // Assert + expect(externalLabel).toBeTruthy(); + }); + + it("displays tunnnel label when app is served via tunnnel", () => { + // Arrange + const activateApp = jest.fn(); + const deactivateApp = jest.fn(); + const removeApp = jest.fn(); + const AppsConfig = getAppsConfig(); + render( + , + ); + const tunnelLabel = screen.queryByTestId("app-tunnel-label"); + + // Assert + expect(tunnelLabel).toBeTruthy(); + }); + + it("calls handlers when active app data passed and buttons clicked", async () => { + // Arrange + const activateApp = jest.fn(); + const deactivateApp = jest.fn(); + const removeApp = jest.fn(); + render( + , + ); + const user = userEvent.setup(); + const activeSwitch = within( + screen.getByTestId("app-active-switch"), + ).getByRole("checkbox"); + const removeButton = screen.getByTestId("app-remove-button"); + + // Assert + expect(activeSwitch).toBeChecked(); + + // Act + await user.click(activeSwitch); + await user.click(removeButton); + + // Assert + expect(deactivateApp).toHaveBeenCalledWith(activeApp.id); + expect(deactivateApp).toHaveBeenCalledTimes(1); + expect(activateApp).not.toHaveBeenCalled(); + expect(removeApp).toHaveBeenCalledWith(activeApp.id); + expect(removeApp).toHaveBeenCalledTimes(1); + }); + + it("calls handlers when inactive app data passed and buttons clicked", async () => { + // Arrange + const activateApp = jest.fn(); + const deactivateApp = jest.fn(); + const removeApp = jest.fn(); + render( + , + ); + const user = userEvent.setup(); + const activeSwitch = within( + screen.getByTestId("app-active-switch"), + ).getByRole("checkbox"); + const removeButton = screen.getByTestId("app-remove-button"); + + // Assert + expect(activeSwitch).not.toBeChecked(); + + // Act + await user.click(activeSwitch); + await user.click(removeButton); + + // Assert + expect(activateApp).toHaveBeenCalledWith(inactiveApp.id); + expect(activateApp).toHaveBeenCalledTimes(1); + expect(deactivateApp).not.toHaveBeenCalled(); + expect(removeApp).toHaveBeenCalledWith(inactiveApp.id); + expect(removeApp).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/new-apps/components/InstalledAppListRow/InstalledAppListRow.tsx b/src/new-apps/components/InstalledAppListRow/InstalledAppListRow.tsx new file mode 100644 index 000000000..a189c4956 --- /dev/null +++ b/src/new-apps/components/InstalledAppListRow/InstalledAppListRow.tsx @@ -0,0 +1,97 @@ +import { Switch, TableCell, Typography } from "@material-ui/core"; +import { TableButtonWrapper } from "@saleor/components/TableButtonWrapper/TableButtonWrapper"; +import TableCellAvatar from "@saleor/components/TableCellAvatar"; +import TableRowLink from "@saleor/components/TableRowLink"; +import { DeleteIcon, IconButton, Pill } from "@saleor/macaw-ui"; +import { useAppListContext } from "@saleor/new-apps/context"; +import { InstalledApp } from "@saleor/new-apps/types"; +import { AppUrls } from "@saleor/new-apps/urls"; +import { isAppInTunnel } from "@saleor/new-apps/utils"; +import clsx from "clsx"; +import React from "react"; +import { useIntl } from "react-intl"; + +import AppManifestTableDisplay from "../AppManifestTableDisplay"; +import { AppPermissions } from "../AppPermissions"; +import { messages } from "./messages"; +import { useStyles } from "./styles"; + +export const InstalledAppListRow: React.FC = ({ + app, + isExternal, +}) => { + const intl = useIntl(); + const classes = useStyles(); + const { activateApp, deactivateApp, removeApp } = useAppListContext(); + + const handleToggleActive = () => { + if (app.isActive) { + deactivateApp(app.id); + } else { + activateApp(app.id); + } + }; + + return ( + + +
+ + {app.name} + + + {`v${app.version}`} + + {isExternal && ( + + )} +
+ {app.manifestUrl && ( + + )} +
+ +
+ {app.manifestUrl && isAppInTunnel(app.manifestUrl) ? ( + + {`(${intl.formatMessage(messages.tunnelDevelopment)})`} + + ) : null} + + + + + + removeApp(app.id)} + data-test-id="app-remove-button" + > + + + +
+
+
+ ); +}; + +export default InstalledAppListRow; diff --git a/src/new-apps/components/InstalledAppListRow/index.ts b/src/new-apps/components/InstalledAppListRow/index.ts new file mode 100644 index 000000000..36b29cd0a --- /dev/null +++ b/src/new-apps/components/InstalledAppListRow/index.ts @@ -0,0 +1,2 @@ +export * from "./InstalledAppListRow"; +export { default } from "./InstalledAppListRow"; diff --git a/src/new-apps/components/InstalledAppListRow/messages.ts b/src/new-apps/components/InstalledAppListRow/messages.ts new file mode 100644 index 000000000..c3ef5fc9e --- /dev/null +++ b/src/new-apps/components/InstalledAppListRow/messages.ts @@ -0,0 +1,14 @@ +import { defineMessages } from "react-intl"; + +export const messages = defineMessages({ + externalApp: { + id: "nA2g2e", + defaultMessage: "External app", + description: "label", + }, + tunnelDevelopment: { + id: "RjT+Vo", + defaultMessage: "Tunnel - development", + description: "label", + }, +}); diff --git a/src/new-apps/components/InstalledAppListRow/styles.ts b/src/new-apps/components/InstalledAppListRow/styles.ts new file mode 100644 index 000000000..d79ababe7 --- /dev/null +++ b/src/new-apps/components/InstalledAppListRow/styles.ts @@ -0,0 +1,55 @@ +import { makeStyles } from "@saleor/macaw-ui"; + +export const useStyles = makeStyles( + theme => ({ + mainContent: { + display: "flex", + alignItems: "center", + gap: theme.spacing(2), + }, + name: { + fontWeight: 600, + }, + version: { + color: theme.palette.saleor.main[3], + }, + tunnel: { + color: theme.palette.saleor.main[3], + whiteSpace: "nowrap", + }, + actions: { + alignItems: "center", + display: "flex", + flexDirection: "row", + justifyContent: "flex-end", + textAlign: "right", + gap: theme.spacing(1), + }, + externalAppLabel: { + cursor: "pointer", + }, + row: { + cursor: "pointer", + }, + col: { + paddingTop: theme.spacing(1), + paddingBottom: theme.spacing(1), + }, + colLogo: { + width: "100%", + "&.MuiTableCell-root:first-child:not(.MuiTableCell-paddingCheckbox)": { + paddingLeft: theme.spacing(3), + width: "100%", + }, + height: "auto", + }, + colActions: { + "&&": { + paddingRight: theme.spacing(3), + textAlign: "right", + }, + height: "auto", + }, + }), + { name: "InstalledAppListRow" }, +); diff --git a/src/new-apps/components/MarketplaceAlert/MarketplaceAlert.tsx b/src/new-apps/components/MarketplaceAlert/MarketplaceAlert.tsx new file mode 100644 index 000000000..a99109e27 --- /dev/null +++ b/src/new-apps/components/MarketplaceAlert/MarketplaceAlert.tsx @@ -0,0 +1,42 @@ +import { Typography } from "@material-ui/core"; +import { Alert } from "@saleor/macaw-ui"; +import React from "react"; +import { FormattedMessage, useIntl } from "react-intl"; + +import msgs from "./messages"; +import { useStyles } from "./styles"; + +interface MarketplaceAlertProps { + error?: Error; +} + +export const MarketplaceAlert: React.FC = ({ + error, +}) => { + const classes = useStyles(); + const intl = useIntl(); + + if (!error) { + return null; + } + + return ( + + + {error.message}, + }} + /> + + + ); +}; +MarketplaceAlert.displayName = "MarketplaceAlert"; +export default MarketplaceAlert; diff --git a/src/new-apps/components/MarketplaceAlert/index.ts b/src/new-apps/components/MarketplaceAlert/index.ts new file mode 100644 index 000000000..e9674ac90 --- /dev/null +++ b/src/new-apps/components/MarketplaceAlert/index.ts @@ -0,0 +1,2 @@ +export { MarketplaceAlert } from "./MarketplaceAlert"; +export { default } from "./MarketplaceAlert"; diff --git a/src/new-apps/components/MarketplaceAlert/messages.ts b/src/new-apps/components/MarketplaceAlert/messages.ts new file mode 100644 index 000000000..332f9ee84 --- /dev/null +++ b/src/new-apps/components/MarketplaceAlert/messages.ts @@ -0,0 +1,14 @@ +import { defineMessages } from "react-intl"; + +export default defineMessages({ + marketplaceError: { + id: "cMov2n", + defaultMessage: "Error loading apps marketplace", + description: "error message", + }, + marketplaceErrorMessage: { + id: "F2cR19", + defaultMessage: "Error details: {message}", + description: "error message", + }, +}); diff --git a/src/new-apps/components/MarketplaceAlert/styles.ts b/src/new-apps/components/MarketplaceAlert/styles.ts new file mode 100644 index 000000000..3b77f2b93 --- /dev/null +++ b/src/new-apps/components/MarketplaceAlert/styles.ts @@ -0,0 +1,10 @@ +import { makeStyles } from "@saleor/macaw-ui"; + +export const useStyles = makeStyles( + theme => ({ + alert: { + margin: theme.spacing(6, 0, 12, 0), + }, + }), + { name: "MarketplaceAlert" }, +); diff --git a/src/new-apps/components/SectionHeader/SectionHeader.tsx b/src/new-apps/components/SectionHeader/SectionHeader.tsx new file mode 100644 index 000000000..76025da08 --- /dev/null +++ b/src/new-apps/components/SectionHeader/SectionHeader.tsx @@ -0,0 +1,20 @@ +import { Typography } from "@material-ui/core"; +import React from "react"; + +import { useStyles } from "./styles"; + +interface SectionHeaderProps { + title: string; +} + +export const SectionHeader: React.FC = ({ title }) => { + const classes = useStyles(); + + return ( + + {title} + + ); +}; +SectionHeader.displayName = "SectionHeader"; +export default SectionHeader; diff --git a/src/new-apps/components/SectionHeader/index.ts b/src/new-apps/components/SectionHeader/index.ts new file mode 100644 index 000000000..2e451f3ab --- /dev/null +++ b/src/new-apps/components/SectionHeader/index.ts @@ -0,0 +1,2 @@ +export * from "./SectionHeader"; +export { default } from "./SectionHeader"; diff --git a/src/new-apps/components/SectionHeader/styles.ts b/src/new-apps/components/SectionHeader/styles.ts new file mode 100644 index 000000000..aec6c4ace --- /dev/null +++ b/src/new-apps/components/SectionHeader/styles.ts @@ -0,0 +1,14 @@ +import { makeStyles } from "@saleor/macaw-ui"; + +export const useStyles = makeStyles( + theme => ({ + sectionHeader: { + fontSize: 14, + fontWeight: 700, + color: theme.palette.saleor.main[3], + margin: theme.spacing(8, 0, 3, 0), + textTransform: "uppercase", + }, + }), + { name: "AppListPage" }, +); diff --git a/src/new-apps/context.ts b/src/new-apps/context.ts new file mode 100644 index 000000000..136974f99 --- /dev/null +++ b/src/new-apps/context.ts @@ -0,0 +1,22 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ +import React from "react"; + +export interface AppListContextValues { + activateApp: (appId: string) => void; + deactivateApp: (appId: string) => void; + removeApp: (appId: string) => void; +} + +export const AppListContext = React.createContext< + AppListContextValues | undefined +>(undefined); + +export const useAppListContext = () => { + const context = React.useContext(AppListContext); + + if (!context) { + throw new Error("useAppListContext must be used within a AppListContext"); + } + + return context; +}; diff --git a/src/new-apps/fixtures.ts b/src/new-apps/fixtures.ts new file mode 100644 index 000000000..a38fa872b --- /dev/null +++ b/src/new-apps/fixtures.ts @@ -0,0 +1,198 @@ +import { + AppListItemFragment, + AppManifestFragment, + AppsInstallationsQuery, + AppTypeEnum, + JobStatusEnum, + PermissionEnum, +} from "@saleor/graphql"; + +import { GetV2SaleorAppsResponse } from "./marketplace.types"; + +export const activeApp: AppListItemFragment = { + __typename: "App", + id: "QXBwOjE3Ng==", + isActive: true, + name: "First App", + type: AppTypeEnum.THIRDPARTY, + version: "1.0.0", + appUrl: "http://localhost:3000", + manifestUrl: "http://localhost:3000/api/manifest", + permissions: [ + { + __typename: "Permission", + code: PermissionEnum.MANAGE_USERS, + name: "Manage customers.", + }, + ], +}; + +export const inactiveApp: AppListItemFragment = { + __typename: "App", + id: "QXBwOj4TMb==", + isActive: false, + name: "Second App", + type: AppTypeEnum.THIRDPARTY, + version: "1.0.0", + appUrl: null, + manifestUrl: "http://localhost:3000/api/manifest", + permissions: [ + { + __typename: "Permission", + code: PermissionEnum.MANAGE_ORDERS, + name: "Manage orders.", + }, + { + __typename: "Permission", + code: PermissionEnum.MANAGE_USERS, + name: "Manage customers.", + }, + ], +}; + +export const installedAppsList: AppListItemFragment[] = [ + activeApp, + inactiveApp, +]; + +export const appsInProgress: AppsInstallationsQuery["appsInstallations"] = [ + { + __typename: "AppInstallation", + appName: "app", + id: "QXBwSW5zdGFsbGF0aW9uOjk2", + manifestUrl: "http://localhost:3000/manifest", + message: "Failed to connect to app. Try later or contact with app support.", + status: JobStatusEnum.FAILED, + }, + { + __typename: "AppInstallation", + appName: "app pending", + id: "QXBwSW5zdGFsbGF0aW9uOjk2", + manifestUrl: "http://localhost:3000/manifest", + message: "Pending.", + status: JobStatusEnum.PENDING, + }, + { + __typename: "AppInstallation", + appName: "app success", + id: "QXBwSW5zdGFsbGF0aW9uOjk2", + manifestUrl: "http://localhost:3000/manifest", + message: "Success.", + status: JobStatusEnum.SUCCESS, + }, +]; + +export const installApp: AppManifestFragment = { + __typename: "Manifest", + about: "Lorem ipsum", + appUrl: null, + configurationUrl: null, + dataPrivacy: null, + dataPrivacyUrl: null, + homepageUrl: null, + identifier: "app", + name: "app", + permissions: [ + { + __typename: "Permission", + code: PermissionEnum.MANAGE_USERS, + name: "Manage users", + }, + { + __typename: "Permission", + code: PermissionEnum.MANAGE_ORDERS, + name: "Manage orders", + }, + ], + supportUrl: null, + tokenTargetUrl: null, + version: "1.0", +}; + +export const releasedApp: GetV2SaleorAppsResponse.ReleasedSaleorApp = { + name: { + en: "Test released app", + }, + description: { + en: "Test released app description", + }, + logo: { + source: "https://www.released-example.com/images/logo.png", + color: "#000000", + }, + manifestUrl: "https://www.released-example.com/manifest", + privacyUrl: "https://www.released-example.com/privacy", + supportUrl: "https://www.released-example.com/support", + repositoryUrl: "https://www.released-example.com/repository", + vercelDeploymentUrl: "https://www.released-example.com/deployment", + integrations: [ + { + name: "First released integration", + logo: { + light: { + source: + "https://www.released-example.com/images/first-integration-logo-light.png", + }, + dark: { + source: + "https://www.released-example.com/images/first-integration-logo-dark.png", + }, + }, + }, + { + name: "Second released integration", + logo: { + light: { + source: + "https://www.released-example.com/images/second-integration-logo-light.png", + }, + dark: { + source: + "https://www.released-example.com/images/second-integration-logo-dark.png", + }, + }, + }, + ], +}; + +export const comingSoonApp: GetV2SaleorAppsResponse.ComingSoonSaleorApp = { + name: { + en: "Test coming soon app", + }, + description: { + en: "Test coming soon app description", + }, + logo: { + source: "https://www.coming-soon-example.com/images/logo.png", + color: "#000000", + }, + releaseDate: "2019-12-16", + integrations: [ + { + name: "First coming soon integration", + logo: { + light: { + source: + "https://www.coming-soon-example.com/images/first-integration-logo-light.png", + }, + dark: { + source: + "https://www.coming-soon-example.com/images/first-integration-logo-dark.png", + }, + }, + }, + { + name: "Second coming soon integration", + logo: { + light: { + source: + "https://www.coming-soon-example.com/images/second-integration-logo-light.png", + }, + dark: { + source: + "https://www.coming-soon-example.com/images/second-integration-logo-dark.png", + }, + }, + }, + ], +}; diff --git a/src/new-apps/hooks/useMarketplaceApps.test.ts b/src/new-apps/hooks/useMarketplaceApps.test.ts new file mode 100644 index 000000000..13a58dd86 --- /dev/null +++ b/src/new-apps/hooks/useMarketplaceApps.test.ts @@ -0,0 +1,66 @@ +import { renderHook } from "@testing-library/react-hooks"; + +import { comingSoonApp, releasedApp } from "../fixtures"; +import useMarketplaceApps from "./useMarketplaceApps"; + +const mockApps = [releasedApp, comingSoonApp]; + +global.fetch = jest.fn(url => { + if (url === "https://marketplace.com/apps") { + return Promise.resolve(({ + ok: true, + json: jest.fn(() => Promise.resolve(mockApps)), + } as unknown) as Response); + } + if (url === "https://marketplace.com/failing-apps-endpoint") { + return Promise.resolve(({ + ok: false, + statusText: "API error", + } as unknown) as Response); + } + return Promise.reject(new Error("API is down")); +}); + +describe("apps hooks useMarketplaceApps", () => { + it("should return apps when request to proper marketplace url returns apps", async () => { + // Arrange + const marketplaceUrl = "https://marketplace.com/apps"; + + // Act + const { result, waitForNextUpdate } = renderHook(() => + useMarketplaceApps(marketplaceUrl), + ); + await waitForNextUpdate(); + + // Assert + expect(result.current).toEqual({ data: mockApps }); + }); + + it("should return error when request to proper marketplace url returns error", async () => { + // Arrange + const marketplaceUrl = "https://marketplace.com/failing-apps-endpoint"; + + // Act + const { result, waitForNextUpdate } = renderHook(() => + useMarketplaceApps(marketplaceUrl), + ); + await waitForNextUpdate(); + + // Assert + expect(result.current).toEqual({ error: Error("API error") }); + }); + + it("should return error when request to wrong marketplace url fails", async () => { + // Arrange + const marketplaceUrl = "https://wrong-marketplace.com"; + + // Act + const { result, waitForNextUpdate } = renderHook(() => + useMarketplaceApps(marketplaceUrl), + ); + await waitForNextUpdate(); + + // Assert + expect(result.current).toEqual({ error: Error("API is down") }); + }); +}); diff --git a/src/new-apps/hooks/useMarketplaceApps.ts b/src/new-apps/hooks/useMarketplaceApps.ts new file mode 100644 index 000000000..16e63e151 --- /dev/null +++ b/src/new-apps/hooks/useMarketplaceApps.ts @@ -0,0 +1,107 @@ +import { GetV2SaleorAppsResponse } from "@saleor/new-apps/marketplace.types"; +import { useEffect, useReducer, useRef } from "react"; + +interface State { + data?: GetV2SaleorAppsResponse.SaleorApp[]; + error?: Error; +} + +interface Cache { + [url: string]: GetV2SaleorAppsResponse.SaleorApp[]; +} + +// discriminated union type +type Action = + | { type: "loading" } + | { type: "fetched"; payload: GetV2SaleorAppsResponse.SaleorApp[] } + | { type: "error"; payload: Error }; + +/** + * Hook used to fetch apps list available under given marketplace url. + * @param marketplaceUrl - url from which fetch data with apps list + * @returns state object containing data with apps list or fetch error + */ +function useMarketplaceApps(marketplaceUrl?: string): State { + const cache = useRef({}); + + // Used to prevent state update if the component is unmounted + const cancelRequest = useRef(false); + + const initialState: State = { + error: undefined, + data: undefined, + }; + + // Keep state logic separated + const fetchReducer = (state: State, action: Action): State => { + switch (action.type) { + case "loading": + return { ...initialState }; + case "fetched": + return { + ...initialState, + data: action.payload, + error: undefined, + }; + case "error": + return { ...initialState, error: action.payload }; + default: + return state; + } + }; + + const [state, dispatch] = useReducer(fetchReducer, initialState); + + useEffect(() => { + // Do nothing if the url is not given + if (!marketplaceUrl) { + return; + } + + cancelRequest.current = false; + + const fetchData = async () => { + dispatch({ type: "loading" }); + + // If a cache exists for this url, return it + if (cache.current[marketplaceUrl]) { + dispatch({ type: "fetched", payload: cache.current[marketplaceUrl] }); + return; + } + + try { + const response = await fetch(marketplaceUrl); + if (!response.ok) { + throw new Error(response.statusText); + } + + const data = (await response.json()) as GetV2SaleorAppsResponse.SaleorApp[]; + cache.current[marketplaceUrl] = data; + if (cancelRequest.current) { + return; + } + + dispatch({ type: "fetched", payload: data }); + } catch (error) { + if (cancelRequest.current) { + return; + } + + dispatch({ type: "error", payload: error as Error }); + } + }; + + void fetchData(); + + // Use the cleanup function for avoiding a possibly... + // ...state update after the component was unmounted + return () => { + cancelRequest.current = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [marketplaceUrl]); + + return state; +} + +export default useMarketplaceApps; diff --git a/src/new-apps/index.tsx b/src/new-apps/index.tsx new file mode 100644 index 000000000..f24d4c3a1 --- /dev/null +++ b/src/new-apps/index.tsx @@ -0,0 +1,31 @@ +import { sectionNames } from "@saleor/intl"; +import { parse as parseQs } from "qs"; +import React from "react"; +import { useIntl } from "react-intl"; +import { Route, RouteComponentProps, Switch } from "react-router-dom"; + +import { WindowTitle } from "../components/WindowTitle"; +import { AppListUrlQueryParams, AppPaths } from "./urls"; +import AppListView from "./views/AppList"; + +const AppList: React.FC = () => { + const qs = parseQs(location.search.substr(1)); + const params: AppListUrlQueryParams = qs; + + return ; +}; + +const Apps = () => { + const intl = useIntl(); + + return ( + <> + + + + + + ); +}; + +export default Apps; diff --git a/src/new-apps/marketplace.types.ts b/src/new-apps/marketplace.types.ts new file mode 100644 index 000000000..c1cc977bd --- /dev/null +++ b/src/new-apps/marketplace.types.ts @@ -0,0 +1,40 @@ +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace GetV2SaleorAppsResponse { + export interface SaleorAppBase { + name: { + en: string; + }; + description: { + en: string; + }; + logo: { + source: string | null; + color: string; + }; + integrations: Array<{ + name: string; + logo: { + light: { + source: string; + }; + dark: { + source: string; + }; + }; + }>; + } + + export type ReleasedSaleorApp = SaleorAppBase & { + repositoryUrl: string; + supportUrl: string; + privacyUrl: string; + manifestUrl: string; + vercelDeploymentUrl?: string; + }; + + export type ComingSoonSaleorApp = SaleorAppBase & { + releaseDate: string; + }; + + export type SaleorApp = ReleasedSaleorApp | ComingSoonSaleorApp; +} diff --git a/src/new-apps/messages.ts b/src/new-apps/messages.ts new file mode 100644 index 000000000..79d7c46b4 --- /dev/null +++ b/src/new-apps/messages.ts @@ -0,0 +1,24 @@ +import { defineMessages } from "react-intl"; + +export const appsMessages = defineMessages({ + repository: { + id: "f91E8b", + defaultMessage: "Repository", + description: "app repository", + }, + support: { + id: "/eTYOp", + defaultMessage: "Support", + description: "app support", + }, + dataPrivacy: { + id: "GKQGKG", + defaultMessage: "Data privacy", + description: "app data privacy", + }, + appManifestUrl: { + id: "4IgzXs", + defaultMessage: "App Manifest URL", + description: "label", + }, +}); diff --git a/src/new-apps/tsconfig.json b/src/new-apps/tsconfig.json new file mode 100644 index 000000000..6149d437f --- /dev/null +++ b/src/new-apps/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "strictNullChecks": true + } +} diff --git a/src/new-apps/types.ts b/src/new-apps/types.ts new file mode 100644 index 000000000..8fa9fb91a --- /dev/null +++ b/src/new-apps/types.ts @@ -0,0 +1,11 @@ +import { AppListItemFragment } from "@saleor/graphql"; + +export interface InstalledApp { + app: AppListItemFragment; + isExternal: boolean; +} + +export interface AppLink { + name: string; + url: string; +} diff --git a/src/new-apps/urls.ts b/src/new-apps/urls.ts new file mode 100644 index 000000000..d16f21e3d --- /dev/null +++ b/src/new-apps/urls.ts @@ -0,0 +1,27 @@ +import { AppDetailsUrlQueryParams } from "@saleor/apps/urls"; +import { stringifyQs } from "@saleor/utils/urls"; +import urlJoin from "url-join"; + +import { Dialog, SingleAction } from "../types"; + +export type AppListUrlDialog = "remove-app" | "app-activate" | "app-deactivate"; +export type AppListUrlQueryParams = Dialog & SingleAction; + +export const AppSections = { + appsSection: "/new-apps/", +}; + +export const AppPaths = { + appListPath: AppSections.appsSection, + resolveAppPath: (id: string) => urlJoin(AppSections.appsSection, id, "app"), + appInstallPath: urlJoin(AppSections.appsSection, "install"), +}; + +export const AppUrls = { + resolveAppListUrl: (params?: AppListUrlQueryParams) => + AppPaths.appListPath + "?" + stringifyQs(params), + resolveAppUrl: (id: string, params?: AppDetailsUrlQueryParams) => + AppPaths.resolveAppPath(encodeURIComponent(id)) + "?" + stringifyQs(params), + resolveAppInstallUrl: (manifestUrl: string) => + `${AppPaths.appInstallPath}?manifestUrl=${manifestUrl}`, +}; diff --git a/src/new-apps/utils.test.ts b/src/new-apps/utils.test.ts new file mode 100644 index 000000000..12dbb444a --- /dev/null +++ b/src/new-apps/utils.test.ts @@ -0,0 +1,90 @@ +import { intlMock } from "@test/intl"; + +import { GetV2SaleorAppsResponse } from "./marketplace.types"; +import { getAppDetails } from "./utils"; + +type AppDetails = ReturnType; + +describe("App utils", () => { + it("should return app details when required released app data passed", () => { + // Arrange + const app: GetV2SaleorAppsResponse.ReleasedSaleorApp = { + name: { + en: "Test app", + }, + description: { + en: "Test app description", + }, + logo: { + source: "https://www.example.com/logo", + color: "#000000", + }, + integrations: [], + manifestUrl: "https://www.example.com/manifest", + privacyUrl: "https://www.example.com/privacy", + supportUrl: "https://www.example.com/support", + repositoryUrl: "https://www.example.com/repository", + vercelDeploymentUrl: "https://www.example.com/deployment", + }; + + // Act + const details = getAppDetails( + intlMock, + app, + () => undefined, + () => undefined, + ); + + // Assert + const expectedDetails: AppDetails = { + releaseDate: undefined, + installHandler: expect.any(Function), + vercelDeployHandler: expect.any(Function), + links: [ + { + name: expect.any(String), + url: "https://www.example.com/repository", + }, + { + name: expect.any(String), + url: "https://www.example.com/support", + }, + { + name: expect.any(String), + url: "https://www.example.com/privacy", + }, + ], + }; + expect(details).toEqual(expectedDetails); + }); + + it("should return app details when required coming soon app data passed", () => { + // Arrange + const app: GetV2SaleorAppsResponse.ComingSoonSaleorApp = { + name: { + en: "Test app", + }, + description: { + en: "Test app description", + }, + logo: { + source: "https://www.example.com/logo", + color: "#000000", + }, + integrations: [], + releaseDate: "2019-12-16", + }; + + // Act + const details = getAppDetails(intlMock, app); + + // Assert + const expectedDetails: AppDetails = { + releaseDate: "2019-12-16", + installHandler: undefined, + vercelDeployHandler: undefined, + links: [], + }; + expect(details).toEqual(expectedDetails); + }); +}); diff --git a/src/new-apps/utils.ts b/src/new-apps/utils.ts new file mode 100644 index 000000000..28b4c10d3 --- /dev/null +++ b/src/new-apps/utils.ts @@ -0,0 +1,94 @@ +import { getAppsConfig } from "@saleor/config"; +import { IntlShape } from "react-intl"; + +import { GetV2SaleorAppsResponse } from "./marketplace.types"; +import { appsMessages } from "./messages"; +import { AppLink } from "./types"; + +const getInstallableMarketplaceApps = ( + marketplaceAppList?: GetV2SaleorAppsResponse.SaleorApp[], +) => + marketplaceAppList?.filter( + app => "manifestUrl" in app || "vercelDeploymentUrl" in app, + ) as GetV2SaleorAppsResponse.ReleasedSaleorApp[] | undefined; + +const getComingSoonMarketplaceApps = ( + marketplaceAppList?: GetV2SaleorAppsResponse.SaleorApp[], +) => + marketplaceAppList?.filter( + app => + !("manifestUrl" in app) && + !("vercelDeploymentUrl" in app) && + "releaseDate" in app, + ) as GetV2SaleorAppsResponse.ComingSoonSaleorApp[] | undefined; + +export const getMarketplaceAppsLists = ( + isMarketplaceAvailable: boolean, + marketplaceAppList?: GetV2SaleorAppsResponse.SaleorApp[], +) => { + if (!isMarketplaceAvailable) { + return { + installableMarketplaceApps: [], + comingSoonMarketplaceApps: [], + }; + } + + return { + installableMarketplaceApps: getInstallableMarketplaceApps( + marketplaceAppList, + ), + comingSoonMarketplaceApps: getComingSoonMarketplaceApps(marketplaceAppList), + }; +}; + +export const isAppInTunnel = (manifestUrl: string) => + Boolean( + getAppsConfig().tunnelUrlKeywords.find(keyword => + new URL(manifestUrl).host.includes(keyword), + ), + ); + +const prepareAppLinks = ( + intl: IntlShape, + app: GetV2SaleorAppsResponse.ReleasedSaleorApp, +): AppLink[] => [ + { + name: intl.formatMessage(appsMessages.repository), + url: app.repositoryUrl, + }, + { + name: intl.formatMessage(appsMessages.support), + url: app.supportUrl, + }, + { + name: intl.formatMessage(appsMessages.dataPrivacy), + url: app.privacyUrl, + }, +]; + +export const getAppDetails = ( + intl: IntlShape, + app: GetV2SaleorAppsResponse.SaleorApp, + navigateToAppInstallPage?: (url: string) => void, + navigateToVercelDeploymentPage?: (url?: string) => void, +) => { + const isAppComingSoon = + !("manifestUrl" in app) && + !("vercelDeploymentUrl" in app) && + "releaseDate" in app; + const isAppInstallable = "manifestUrl" in app && !!navigateToAppInstallPage; + const isAppVercelDeployable = + "vercelDeploymentUrl" in app && !!navigateToVercelDeploymentPage; + + return { + releaseDate: isAppComingSoon ? app.releaseDate : undefined, + installHandler: isAppInstallable + ? () => navigateToAppInstallPage(app.manifestUrl) + : undefined, + vercelDeployHandler: + isAppVercelDeployable && !!app.vercelDeploymentUrl + ? () => navigateToVercelDeploymentPage(app.vercelDeploymentUrl) + : undefined, + links: isAppComingSoon ? [] : prepareAppLinks(intl, app), + }; +}; diff --git a/src/new-apps/views/AppList/AppList.tsx b/src/new-apps/views/AppList/AppList.tsx new file mode 100644 index 000000000..2bb47b268 --- /dev/null +++ b/src/new-apps/views/AppList/AppList.tsx @@ -0,0 +1,206 @@ +import { useApolloClient } from "@apollo/client"; +import { EXTENSION_LIST_QUERY } from "@saleor/apps/queries"; +import { getAppsConfig } from "@saleor/config"; +import { + AppSortField, + AppTypeEnum, + OrderDirection, + useAppActivateMutation, + useAppDeactivateMutation, + useAppDeleteMutation, + useAppsListQuery, +} from "@saleor/graphql"; +import useListSettings from "@saleor/hooks/useListSettings"; +import useLocalPaginator, { + useLocalPaginationState, +} from "@saleor/hooks/useLocalPaginator"; +import useNavigator from "@saleor/hooks/useNavigator"; +import useNotifier from "@saleor/hooks/useNotifier"; +import { PaginatorContext } from "@saleor/hooks/usePaginator"; +import { findById } from "@saleor/misc"; +import AppActivateDialog from "@saleor/new-apps/components/AppActivateDialog"; +import AppDeactivateDialog from "@saleor/new-apps/components/AppDeactivateDialog"; +import AppDeleteDialog from "@saleor/new-apps/components/AppDeleteDialog"; +import AppListPage from "@saleor/new-apps/components/AppListPage/AppListPage"; +import { AppListContext, AppListContextValues } from "@saleor/new-apps/context"; +import useMarketplaceApps from "@saleor/new-apps/hooks/useMarketplaceApps"; +import { + AppListUrlDialog, + AppListUrlQueryParams, + AppUrls, +} from "@saleor/new-apps/urls"; +import { getMarketplaceAppsLists } from "@saleor/new-apps/utils"; +import { ListViews } from "@saleor/types"; +import createDialogActionHandlers from "@saleor/utils/handlers/dialogActionHandlers"; +import { mapEdgesToItems } from "@saleor/utils/maps"; +import React from "react"; +import { useIntl } from "react-intl"; + +import { messages } from "./messages"; + +interface AppsListProps { + params: AppListUrlQueryParams; +} + +export const AppsList: React.FC = ({ params }) => { + const navigate = useNavigator(); + const notify = useNotifier(); + const intl = useIntl(); + const client = useApolloClient(); + const [openModal, closeModal] = createDialogActionHandlers< + AppListUrlDialog, + AppListUrlQueryParams + >(navigate, AppUrls.resolveAppListUrl, params); + const AppsConfig = getAppsConfig(); + + const { updateListSettings, settings } = useListSettings(ListViews.APPS_LIST); + const queryVariables = { + sort: { + direction: OrderDirection.DESC, + field: AppSortField.CREATION_DATE, + }, + }; + + const [paginationState, setPaginationState] = useLocalPaginationState( + settings?.rowNumber, + ); + const paginate = useLocalPaginator(setPaginationState); + + const { data: installedAppsData, loading, refetch } = useAppsListQuery({ + displayLoader: true, + variables: { + ...paginationState, + ...queryVariables, + filter: { + type: AppTypeEnum.THIRDPARTY, + }, + }, + }); + const { pageInfo, ...paginationValues } = paginate( + installedAppsData?.apps?.pageInfo, + paginationState, + ); + + const refetchExtensionList = () => { + client.refetchQueries({ + include: [EXTENSION_LIST_QUERY], + }); + }; + + const handleRemoveConfirm = () => + deleteApp({ + variables: { + id: params.id || "", + }, + }); + + const removeAppNotify = () => { + notify({ + status: "success", + text: intl.formatMessage(messages.appRemoved), + }); + }; + + const handleActivateAppConfirm = () => + activateApp({ variables: { id: params.id || "" } }); + + const handleDeactivateAppConfirm = () => + deactivateApp({ variables: { id: params.id || "" } }); + + const [deleteApp, deleteAppOpts] = useAppDeleteMutation({ + onCompleted: data => { + if (!data?.appDelete?.errors?.length) { + refetch(); + closeModal(); + refetchExtensionList(); + removeAppNotify(); + } + }, + }); + const [activateApp, activateAppResult] = useAppActivateMutation({ + onCompleted: data => { + if (!data?.appActivate?.errors?.length) { + notify({ + status: "success", + text: intl.formatMessage(messages.appActivated), + }); + refetch(); + closeModal(); + } + }, + }); + const [deactivateApp, deactivateAppResult] = useAppDeactivateMutation({ + onCompleted: data => { + if (!data?.appDeactivate?.errors?.length) { + notify({ + status: "success", + text: intl.formatMessage(messages.appDeactivated), + }); + refetch(); + closeModal(); + } + }, + }); + + const context: AppListContextValues = React.useMemo( + () => ({ + activateApp: id => openModal("app-activate", { id }), + deactivateApp: id => openModal("app-deactivate", { id }), + removeApp: id => openModal("remove-app", { id }), + }), + [activateApp, deactivateApp, deleteApp], + ); + + const { data: marketplaceAppList, error } = useMarketplaceApps( + AppsConfig.marketplaceApiUri, + ); + + const { + installableMarketplaceApps, + comingSoonMarketplaceApps, + } = getMarketplaceAppsLists( + !!AppsConfig.marketplaceApiUri, + marketplaceAppList, + ); + const installedApps = mapEdgesToItems(installedAppsData?.apps); + const currentAppName = params.id && findById(params.id, installedApps)?.name; + + return ( + + + + + + + + + ); +}; +export default AppsList; diff --git a/src/new-apps/views/AppList/index.ts b/src/new-apps/views/AppList/index.ts new file mode 100644 index 000000000..e6e983caf --- /dev/null +++ b/src/new-apps/views/AppList/index.ts @@ -0,0 +1,2 @@ +export * from "./AppList"; +export { default } from "./AppList"; diff --git a/src/new-apps/views/AppList/messages.ts b/src/new-apps/views/AppList/messages.ts new file mode 100644 index 000000000..bb190189f --- /dev/null +++ b/src/new-apps/views/AppList/messages.ts @@ -0,0 +1,19 @@ +import { defineMessages } from "react-intl"; + +export const messages = defineMessages({ + appRemoved: { + id: "uIPD1i", + defaultMessage: "App successfully removed", + description: "app has been removed", + }, + appActivated: { + id: "D/+84n", + defaultMessage: "App activated", + description: "snackbar text", + }, + appDeactivated: { + id: "USO8PB", + defaultMessage: "App deactivated", + description: "snackbar text", + }, +}); diff --git a/src/utils/maps.ts b/src/utils/maps.ts index ee8c98d6d..0a9d81bd9 100644 --- a/src/utils/maps.ts +++ b/src/utils/maps.ts @@ -21,7 +21,7 @@ interface Connection { } export function mapEdgesToItems( - data: Connection | undefined, + data?: Connection | undefined | null, ): T[] | undefined { return data?.edges?.map(({ node }) => node); } diff --git a/testUtils/intl.ts b/testUtils/intl.ts index 4cf9919de..555bc9ad1 100644 --- a/testUtils/intl.ts +++ b/testUtils/intl.ts @@ -1,6 +1,10 @@ -import { IntlConfig } from "react-intl/src/types"; +import { IntlConfig, IntlShape } from "react-intl/src/types"; export const config: IntlConfig = { defaultLocale: "en", locale: "en", }; + +export const intlMock = { + formatMessage: ({ defaultMessage }) => defaultMessage, +} as IntlShape; diff --git a/testUtils/setup.ts b/testUtils/setup.ts index bb0fcbc80..afa38019b 100644 --- a/testUtils/setup.ts +++ b/testUtils/setup.ts @@ -7,10 +7,12 @@ document.getElementById = () => document.createElement("div"); window.__SALEOR_CONFIG__ = { API_URL: "http://localhost:8000/graphql/", APP_MOUNT_URI: "/", - MARKETPLACE_URL: "http//localhost:3000", + MARKETPLACE_URL: "http://localhost:3000", SALEOR_APPS_PAGE_PATH: "/saleor-apps", SALEOR_APPS_JSON_PATH: "/api/saleor-apps", APP_TEMPLATE_GALLERY_PATH: "/template-gallery", + APPS_MARKETPLACE_API_URI: "http://localhost:3000", + APPS_TUNNEL_URL_KEYWORDS: ".ngrok.io;.saleor.live", }; process.env.TZ = "UTC"; diff --git a/types.d.ts b/types.d.ts index 3b0a980f8..220bf60f0 100644 --- a/types.d.ts +++ b/types.d.ts @@ -14,5 +14,7 @@ declare interface Window { SALEOR_APPS_PAGE_PATH: string; SALEOR_APPS_JSON_PATH: string; APP_TEMPLATE_GALLERY_PATH: string; + APPS_MARKETPLACE_API_URI?: string; + APPS_TUNNEL_URL_KEYWORDS?: string; }; } diff --git a/vite.config.js b/vite.config.js index 7f1967f43..9949970a4 100644 --- a/vite.config.js +++ b/vite.config.js @@ -31,6 +31,8 @@ export default defineConfig(({ command, mode }) => { SALEOR_APPS_PAGE_PATH, SALEOR_APPS_JSON_PATH, APP_TEMPLATE_GALLERY_PATH, + APPS_MARKETPLACE_API_URI, + APPS_TUNNEL_URL_KEYWORDS, SKIP_SOURCEMAPS, DEMO_MODE, } = env; @@ -53,6 +55,8 @@ export default defineConfig(({ command, mode }) => { SALEOR_APPS_JSON_PATH, APP_TEMPLATE_GALLERY_PATH, MARKETPLACE_URL, + APPS_MARKETPLACE_API_URI, + APPS_TUNNEL_URL_KEYWORDS, }, }, }),