Apps marketplace (#2850)

* Add new Apps List

* Update apps routing

* Add marketplace apps list

* Update apps urls

* Update app list style

* Add installed apps section

* Update apps sections and connect actions with mutations

* Add latest missing buttons and labels to installed apps list

* Update installed apps list

* Update installed apps list

* Add tests and marketplace error handling

* Update environment configuration

* Update GitHub actions env configuration

* Refactor AppListCard component

* Test InstallWithManifestFormButton

* Test AppListCard

* Extract InstalledAppListRow with tests

* Update GitHub actions env configuration

* Tests of apps dialogs

* Update GitHub actions env configuration

* Update messages

* Update GitHub actions env configuration

* Quote untrusted GitHub actions variables

* Change useFetch to useMarketplaceApps and add tests

* Fix strict null check errors

* Refactor apps details components

* Add strict null checks for /new-apps/ components
This commit is contained in:
Dawid 2023-01-10 11:04:30 +01:00 committed by GitHub
parent ee19e42e7e
commit 1621feb691
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
112 changed files with 3907 additions and 114 deletions

View file

@ -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/ API_URI=https://automation-dashboard.staging.saleor.cloud/graphql/
MARKETPLACE_URL=https://apps.saleor.io 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 SALEOR_APPS_ENDPOINT=https://apps.saleor.io/api/saleor-apps
### Do you want to run more stable tests? ### 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. 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 1. [ ] stable

View file

@ -23,6 +23,7 @@ jobs:
# SALEOR_APPS_PAGE_PATH=saleor-apps # SALEOR_APPS_PAGE_PATH=saleor-apps
# SALEOR_APPS_JSON_PATH=api/saleor-apps # SALEOR_APPS_JSON_PATH=api/saleor-apps
# APP_TEMPLATE_GALLERY_PATH=template-gallery # APP_TEMPLATE_GALLERY_PATH=template-gallery
# APPS_MARKETPLACE_API_URI: "https://apps.saleor.io/api/v2/saleor-apps"
IS_CLOUD_INSTANCE: true IS_CLOUD_INSTANCE: true
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2

View file

@ -26,6 +26,7 @@ jobs:
# SALEOR_APPS_PAGE_PATH=saleor-apps # SALEOR_APPS_PAGE_PATH=saleor-apps
# SALEOR_APPS_JSON_PATH=api/saleor-apps # SALEOR_APPS_JSON_PATH=api/saleor-apps
# APP_TEMPLATE_GALLERY_PATH=template-gallery # APP_TEMPLATE_GALLERY_PATH=template-gallery
# APPS_MARKETPLACE_API_URI: "https://apps.saleor.io/api/v2/saleor-apps"
ENVIRONMENT: demo-staging ENVIRONMENT: demo-staging
DEMO_MODE: true DEMO_MODE: true
steps: steps:

View file

@ -21,6 +21,7 @@ jobs:
# SALEOR_APPS_PAGE_PATH=saleor-apps # SALEOR_APPS_PAGE_PATH=saleor-apps
# SALEOR_APPS_JSON_PATH=api/saleor-apps # SALEOR_APPS_JSON_PATH=api/saleor-apps
# APP_TEMPLATE_GALLERY_PATH=template-gallery # APP_TEMPLATE_GALLERY_PATH=template-gallery
# APPS_MARKETPLACE_API_URI: "https://apps.saleor.io/api/v2/saleor-apps"
ENVIRONMENT: demo ENVIRONMENT: demo
DEMO_MODE: true DEMO_MODE: true
steps: steps:

View file

@ -21,6 +21,7 @@ jobs:
SALEOR_APPS_PAGE_PATH: saleor-apps SALEOR_APPS_PAGE_PATH: saleor-apps
SALEOR_APPS_JSON_PATH: api/saleor-apps SALEOR_APPS_JSON_PATH: api/saleor-apps
APP_TEMPLATE_GALLERY_PATH: template-gallery APP_TEMPLATE_GALLERY_PATH: template-gallery
APPS_MARKETPLACE_API_URI: "https://marketplace-gray.vercel.app/api/v2/saleor-apps"
ENVIRONMENT: saleor-master-staging ENVIRONMENT: saleor-master-staging
IS_CLOUD_INSTANCE: true IS_CLOUD_INSTANCE: true
steps: steps:

View file

@ -28,6 +28,7 @@ jobs:
# SALEOR_APPS_PAGE_PATH=saleor-apps # SALEOR_APPS_PAGE_PATH=saleor-apps
# SALEOR_APPS_JSON_PATH=api/saleor-apps # SALEOR_APPS_JSON_PATH=api/saleor-apps
# APP_TEMPLATE_GALLERY_PATH=template-gallery # 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 }} VERSION: ${{ github.event.inputs.git_ref || github.ref_name }}
IS_CLOUD_INSTANCE: true IS_CLOUD_INSTANCE: true
steps: steps:

View file

@ -108,7 +108,7 @@ jobs:
pattern: (http|https)://[a-zA-Z0-9.-]+/graphql/? pattern: (http|https)://[a-zA-Z0-9.-]+/graphql/?
fallback_uri: ${{ secrets.CYPRESS_API_URI }} fallback_uri: ${{ secrets.CYPRESS_API_URI }}
run: | 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 - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v2

View file

@ -58,7 +58,7 @@ jobs:
prefix: API_URI= prefix: API_URI=
pattern: (http|https)://[a-zA-Z0-9.-]+/graphql/? pattern: (http|https)://[a-zA-Z0-9.-]+/graphql/?
run: | 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 - name: Get MARKETPLACE_URL
id: marketplace_url id: marketplace_url
# Search for MARKETPLACE_URL in PR description # Search for MARKETPLACE_URL in PR description
@ -67,12 +67,22 @@ jobs:
prefix: MARKETPLACE_URL= prefix: MARKETPLACE_URL=
pattern: (http|https)://[a-zA-Z0-9.-]+/? pattern: (http|https)://[a-zA-Z0-9.-]+/?
run: | 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 - name: Run build
env: env:
# Use custom API_URI or the default one # Use custom API_URI or the default one
API_URI: ${{ steps.api_uri.outputs.custom_api_uri || 'https://qa.staging.saleor.cloud/graphql/' }} API_URI: ${{ steps.api_uri.outputs.custom_api_uri || 'https://qa.staging.saleor.cloud/graphql/' }}
MARKETPLACE_URL: ${{ steps.marketplace_url.outputs.custom_marketplace_url }} 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: / APP_MOUNT_URI: /
STATIC_URL: / STATIC_URL: /
IS_CLOUD_INSTANCE: true IS_CLOUD_INSTANCE: true
@ -193,7 +203,7 @@ jobs:
pattern: (http|https)://[a-zA-Z0-9.-]+/graphql/? pattern: (http|https)://[a-zA-Z0-9.-]+/graphql/?
fallback_uri: ${{ secrets.CYPRESS_API_URI }} fallback_uri: ${{ secrets.CYPRESS_API_URI }}
run: | 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 - name: Setup Node
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:

View file

@ -21,6 +21,8 @@ COPY src/ src/
ARG API_URI ARG API_URI
ARG APP_MOUNT_URI ARG APP_MOUNT_URI
ARG MARKETPLACE_URL ARG MARKETPLACE_URL
ARG APPS_MARKETPLACE_API_URI,
ARG APPS_TUNNEL_URL_KEYWORDS,
ARG SALEOR_APPS_ENDPOINT ARG SALEOR_APPS_ENDPOINT
ARG STATIC_URL ARG STATIC_URL
ARG SKIP_SOURCEMAPS ARG SKIP_SOURCEMAPS
@ -28,6 +30,8 @@ ARG SKIP_SOURCEMAPS
ENV API_URI ${API_URI:-http://localhost:8000/graphql/} ENV API_URI ${API_URI:-http://localhost:8000/graphql/}
ENV APP_MOUNT_URI ${APP_MOUNT_URI:-/dashboard/} ENV APP_MOUNT_URI ${APP_MOUNT_URI:-/dashboard/}
ENV MARKETPLACE_URL ${MARKETPLACE_URL} 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 SALEOR_APPS_ENDPOINT=${SALEOR_APPS_ENDPOINT}
ENV STATIC_URL ${STATIC_URL:-/dashboard/} ENV STATIC_URL ${STATIC_URL:-/dashboard/}
ENV SKIP_SOURCEMAPS ${SKIP_SOURCEMAPS:-true} ENV SKIP_SOURCEMAPS ${SKIP_SOURCEMAPS:-true}

View file

@ -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. - `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/". 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.

View file

@ -70,6 +70,10 @@
"+a+2ug": { "+a+2ug": {
"string": "Members" "string": "Members"
}, },
"+b3KCV": {
"context": "button",
"string": "Activate"
},
"+c/f61": { "+c/f61": {
"context": "retry installation", "context": "retry installation",
"string": "Retry" "string": "Retry"
@ -200,6 +204,10 @@
"context": "section header", "context": "section header",
"string": "Media" "string": "Media"
}, },
"/PhkXi": {
"context": "section header",
"string": "Installed Apps"
},
"/TF6BZ": { "/TF6BZ": {
"string": "Search Products" "string": "Search Products"
}, },
@ -233,6 +241,10 @@
"context": "products in collection", "context": "products in collection",
"string": "Products in {name}" "string": "Products in {name}"
}, },
"/eTYOp": {
"context": "app support",
"string": "Support"
},
"/glQgs": { "/glQgs": {
"string": "No channels found" "string": "No channels found"
}, },
@ -378,6 +390,10 @@
"context": "selected customer channel subtitle", "context": "selected customer channel subtitle",
"string": "Customer will be sent the gift card code via this channels email address" "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": { "1+M/52": {
"context": "section header", "context": "section header",
"string": "Webhook Status" "string": "Webhook Status"
@ -617,6 +633,10 @@
"context": "button", "context": "button",
"string": "Enable Dark Mode" "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": { "2yU+q9": {
"context": "tab name", "context": "tab name",
"string": "All Warehouses" "string": "All Warehouses"
@ -784,6 +804,10 @@
"context": "order status", "context": "order status",
"string": "Returned" "string": "Returned"
}, },
"4IgzXs": {
"context": "label",
"string": "App Manifest URL"
},
"4JW9iJ": { "4JW9iJ": {
"context": "home section name", "context": "home section name",
"string": "Home" "string": "Home"
@ -1326,6 +1350,10 @@
"context": "button", "context": "button",
"string": "Back to homepage" "string": "Back to homepage"
}, },
"94oZR0": {
"context": "deactivate app billing info",
"string": "You will be still billed for the app."
},
"95oJ5d": { "95oJ5d": {
"context": "button", "context": "button",
"string": "Go back to dashboard" "string": "Go back to dashboard"
@ -1500,6 +1528,10 @@
"context": "order discount was updated automatically event title", "context": "order discount was updated automatically event title",
"string": "Order discount was updated automatically updated" "string": "Order discount was updated automatically updated"
}, },
"ARfw53": {
"context": "description",
"string": "Install apps created by Saleor team or install external apps."
},
"AVF5T5": { "AVF5T5": {
"context": "voucher end date, switch button", "context": "voucher end date, switch button",
"string": "Set end date" "string": "Set end date"
@ -1849,6 +1881,9 @@
"context": "order history message", "context": "order history message",
"string": "Order was refunded by {refundedBy}" "string": "Order was refunded by {refundedBy}"
}, },
"D3idYv": {
"string": "Settings"
},
"D4CsYK": { "D4CsYK": {
"context": "status filter label", "context": "status filter label",
"string": "Status" "string": "Status"
@ -2088,6 +2123,10 @@
"context": "dialog header", "context": "dialog header",
"string": "Select a page type" "string": "Select a page type"
}, },
"F2cR19": {
"context": "error message",
"string": "Error details: {message}"
},
"F3Upht": { "F3Upht": {
"string": "Product type deleted" "string": "Product type deleted"
}, },
@ -2290,6 +2329,10 @@
"context": "order history message", "context": "order history message",
"string": "Order was placed" "string": "Order was placed"
}, },
"GKQGKG": {
"context": "app data privacy",
"string": "Data privacy"
},
"GLX9II": { "GLX9II": {
"context": "billing address", "context": "billing address",
"string": "Same as shipping address" "string": "Same as shipping address"
@ -2874,6 +2917,10 @@
"context": "dialog title", "context": "dialog title",
"string": "Delete products" "string": "Delete products"
}, },
"KDvRBi": {
"context": "button",
"string": "Install"
},
"KFv8hX": { "KFv8hX": {
"string": "An attribute already exists." "string": "An attribute already exists."
}, },
@ -3117,6 +3164,10 @@
"MYA6EV": { "MYA6EV": {
"string": "Third party apps are installed with App Manifests. They contain UI accessible from dashboard and can extend it." "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": { "MbZHXE": {
"context": "column title balance", "context": "column title balance",
"string": "Balance" "string": "Balance"
@ -3325,6 +3376,10 @@
"O95R3Z": { "O95R3Z": {
"string": "Phone" "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": { "O9QPe1": {
"context": "dialog content", "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?}}" "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": { "Rj8LxK": {
"string": "Add search engine title and description to make this collection easier to find" "string": "Add search engine title and description to make this collection easier to find"
}, },
"RjT+Vo": {
"context": "label",
"string": "Tunnel - development"
},
"Rjs1CD": { "Rjs1CD": {
"context": "button", "context": "button",
"string": "Continue" "string": "Continue"
@ -4330,6 +4389,10 @@
"context": "section header", "context": "section header",
"string": "Staff Member Information" "string": "Staff Member Information"
}, },
"VTq2gn": {
"context": "section header",
"string": "Coming Soon"
},
"VZsE96": { "VZsE96": {
"string": "Collection Name" "string": "Collection Name"
}, },
@ -4671,6 +4734,10 @@
"Xtd0AT": { "Xtd0AT": {
"string": "Original String" "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": { "XwQQ1f": {
"context": "checkbox label description", "context": "checkbox label description",
"string": "All fulfillments will be automatically approved" "string": "All fulfillments will be automatically approved"
@ -5252,6 +5319,10 @@
"context": "input label", "context": "input label",
"string": "New Password" "string": "New Password"
}, },
"cMov2n": {
"context": "error message",
"string": "Error loading apps marketplace"
},
"cNSLLO": { "cNSLLO": {
"context": "button", "context": "button",
"string": "Unassign and save" "string": "Unassign and save"
@ -5509,6 +5580,10 @@
"context": "attributes, section header", "context": "attributes, section header",
"string": "Variant Attributes" "string": "Variant Attributes"
}, },
"f91E8b": {
"context": "app repository",
"string": "Repository"
},
"fEfCtO": { "fEfCtO": {
"context": "voucher discount type", "context": "voucher discount type",
"string": "Percentage" "string": "Percentage"
@ -5703,6 +5778,10 @@
"gxPjIQ": { "gxPjIQ": {
"string": "Are you sure you want to delete {email} from staff members?" "string": "Are you sure you want to delete {email} from staff members?"
}, },
"gygOA1": {
"context": "button",
"string": "Deactivate"
},
"gz9v22": { "gz9v22": {
"context": "PluginChannelConfigurationCell channel title", "context": "PluginChannelConfigurationCell channel title",
"string": "Per channel" "string": "Per channel"
@ -6146,6 +6225,10 @@
"context": "channel publication date", "context": "channel publication date",
"string": "Published since {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/": { "kZfIl/": {
"string": "These are general information about this Content Type." "string": "These are general information about this Content Type."
}, },
@ -6451,6 +6534,10 @@
"context": "money amount input label", "context": "money amount input label",
"string": "Enter amount" "string": "Enter amount"
}, },
"nA2g2e": {
"context": "label",
"string": "External app"
},
"nEWp+k": { "nEWp+k": {
"context": "quantity of ordered products", "context": "quantity of ordered products",
"string": "Quantity" "string": "Quantity"
@ -6513,6 +6600,10 @@
"context": "tax class rates list label when no countries are assigned", "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." "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": { "njBulj": {
"context": "check to require attribute to have value", "context": "check to require attribute to have value",
"string": "Value Required" "string": "Value Required"
@ -6575,6 +6666,10 @@
"context": "order status", "context": "order status",
"string": "Unfulfilled" "string": "Unfulfilled"
}, },
"oGIPb7": {
"context": "information",
"string": "Coming soon ({releaseDate})"
},
"oHbgcK": { "oHbgcK": {
"context": "PageTypeDeleteWarningDialog title", "context": "PageTypeDeleteWarningDialog title",
"string": "Delete page {selectedTypesCount,plural,one{type} other{types}}" "string": "Delete page {selectedTypesCount,plural,one{type} other{types}}"
@ -6619,6 +6714,10 @@
"oYGfnY": { "oYGfnY": {
"string": "ZIP / Postal code" "string": "ZIP / Postal code"
}, },
"ohQrzn": {
"context": "button",
"string": "Deploy to Vercel"
},
"oiuwOl": { "oiuwOl": {
"context": "button", "context": "button",
"string": "Assign" "string": "Assign"
@ -6752,6 +6851,10 @@
"context": "card subtitle", "context": "card subtitle",
"string": "Entered prices" "string": "Entered prices"
}, },
"puUvKO": {
"context": "button",
"string": "Install external app"
},
"puikeb": { "puikeb": {
"context": "button", "context": "button",
"string": "Delete Address" "string": "Delete Address"
@ -7857,6 +7960,10 @@
"yT5zvU": { "yT5zvU": {
"string": "{counter,plural,one{Are you sure you want to delete this collection?} other{Are you sure you want to delete {displayQuantity} collections?}}" "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": { "yatGsm": {
"context": "card title", "context": "card title",
"string": "Order Value" "string": "Order Value"
@ -7911,6 +8018,10 @@
"context": "file attribute type", "context": "file attribute type",
"string": "File" "string": "File"
}, },
"z2yYL3": {
"context": "hint",
"string": "Usually ends with /api/manifest"
},
"z8jo8h": { "z8jo8h": {
"context": "button", "context": "button",
"string": "View products" "string": "View products"

View file

@ -15,6 +15,7 @@ export const appsList: AppListItemFragment[] = [
isActive: true, isActive: true,
name: "app", name: "app",
type: AppTypeEnum.THIRDPARTY, type: AppTypeEnum.THIRDPARTY,
version: "1.0.0",
appUrl: null, appUrl: null,
manifestUrl: "http://localhost:3000/api/manifest", manifestUrl: "http://localhost:3000/api/manifest",
permissions: [ permissions: [
@ -31,6 +32,7 @@ export const appsList: AppListItemFragment[] = [
isActive: false, isActive: false,
name: "app1", name: "app1",
type: AppTypeEnum.THIRDPARTY, type: AppTypeEnum.THIRDPARTY,
version: "1.0.0",
appUrl: "http://localhost:3000", appUrl: "http://localhost:3000",
manifestUrl: "http://localhost:3000/api/manifest", manifestUrl: "http://localhost:3000/api/manifest",
permissions: [ permissions: [
@ -55,6 +57,7 @@ export const customAppsList: AppListItemFragment[] = [
isActive: true, isActive: true,
name: "app custom", name: "app custom",
type: AppTypeEnum.LOCAL, type: AppTypeEnum.LOCAL,
version: "1.0.0",
appUrl: null, appUrl: null,
manifestUrl: null, manifestUrl: null,
permissions: [ permissions: [

View file

@ -47,21 +47,7 @@ export const appFetchMutation = gql`
mutation AppFetch($manifestUrl: String!) { mutation AppFetch($manifestUrl: String!) {
appFetchManifest(manifestUrl: $manifestUrl) { appFetchManifest(manifestUrl: $manifestUrl) {
manifest { manifest {
identifier ...AppManifest
version
about
name
appUrl
configurationUrl
tokenTargetUrl
dataPrivacy
dataPrivacyUrl
homepageUrl
supportUrl
permissions {
code
name
}
} }
errors { errors {
...AppError ...AppError

View file

@ -42,3 +42,5 @@ export const TableButtonWrapper = <T extends HTMLElement>({
return children; return children;
}; };
TableButtonWrapper.displayName = "TableButtonWrapper";
export default TableButtonWrapper;

View file

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

View file

@ -1,44 +1,13 @@
import { Avatar as MuiAvatar } from "@material-ui/core";
import { ImageIcon, makeStyles } from "@saleor/macaw-ui";
import clsx from "clsx"; import clsx from "clsx";
import React from "react"; import React from "react";
import AvatarImage from "./AvatarImage";
import { useAvatarStyles } from "./styles";
export const AVATAR_MARGIN = 40; 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 { export interface AvatarProps {
initials?: string;
thumbnail?: string; thumbnail?: string;
alignRight?: boolean; alignRight?: boolean;
avatarProps?: string; avatarProps?: string;
@ -49,11 +18,12 @@ export interface AvatarProps {
const Avatar: React.FC<AvatarProps> = ({ const Avatar: React.FC<AvatarProps> = ({
children, children,
alignRight, alignRight,
initials,
thumbnail, thumbnail,
avatarProps, avatarProps,
badge, badge,
}) => { }) => {
const classes = useStyles({}); const classes = useAvatarStyles();
return ( return (
<div <div
@ -62,16 +32,11 @@ const Avatar: React.FC<AvatarProps> = ({
})} })}
> >
{badge} {badge}
{!thumbnail ? ( <AvatarImage
<MuiAvatar className={clsx(classes.avatar, avatarProps)}> thumbnail={thumbnail}
<ImageIcon color="primary" data-test-id="imageIcon" /> initials={initials}
</MuiAvatar> avatarProps={avatarProps}
) : (
<MuiAvatar
className={clsx(classes.avatar, avatarProps)}
src={thumbnail}
/> />
)}
{!alignRight && <div className={classes.children}>{children}</div>} {!alignRight && <div className={classes.children}>{children}</div>}
</div> </div>
); );

View file

@ -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<AvatarImageProps> = ({
initials,
thumbnail,
avatarProps,
}) => {
const classes = useAvatarImageStyles();
if (!thumbnail && initials) {
return (
<MuiAvatar className={clsx(classes.avatar, avatarProps)}>
<Typography variant="h3">{initials}</Typography>
</MuiAvatar>
);
}
if (!thumbnail) {
return (
<MuiAvatar className={clsx(classes.avatar, avatarProps)}>
<ImageIcon color="primary" data-test-id="imageIcon" />
</MuiAvatar>
);
}
return (
<MuiAvatar className={clsx(classes.avatar, avatarProps)} src={thumbnail} />
);
};
export default AvatarImage;

View file

@ -1,23 +1,10 @@
import { TableCell } from "@material-ui/core"; import { TableCell } from "@material-ui/core";
import { TableCellProps } from "@material-ui/core/TableCell"; import { TableCellProps } from "@material-ui/core/TableCell";
import { makeStyles } from "@saleor/macaw-ui";
import clsx from "clsx"; import clsx from "clsx";
import React from "react"; import React from "react";
import Avatar, { AvatarProps } from "./Avatar"; import Avatar, { AvatarProps } from "./Avatar";
import { useStyles } from "./styles";
const useStyles = makeStyles(
theme => ({
root: {
"&:not(first-child)": {
paddingLeft: 0,
},
paddingRight: theme.spacing(3),
width: "1%",
},
}),
{ name: "TableCellAvatar" },
);
interface TableCellAvatarProps interface TableCellAvatarProps
extends TableCellProps, extends TableCellProps,

View file

@ -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" },
);

View file

@ -16,6 +16,13 @@ export const MARKETPLACE_SALEOR_APPS_JSON_PATH =
export const MARKETPLACE_APP_TEMPLATE_GALLERY_PATH = export const MARKETPLACE_APP_TEMPLATE_GALLERY_PATH =
window.__SALEOR_CONFIG__.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 = { export const DEFAULT_INITIAL_SEARCH_DATA: SearchVariables = {
after: null, after: null,
first: 20, first: 20,

View file

@ -1,5 +1,25 @@
import { gql } from "@apollo/client"; 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` export const appFragment = gql`
fragment App on App { fragment App on App {
id id
@ -41,6 +61,7 @@ export const appListItemFragment = gql`
type type
appUrl appUrl
manifestUrl manifestUrl
version
permissions { permissions {
...AppPermission ...AppPermission
} }

View file

@ -5,6 +5,25 @@ import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client'; import * as Apollo from '@apollo/client';
import * as ApolloReactHooks from '@saleor/hooks/graphql'; import * as ApolloReactHooks from '@saleor/hooks/graphql';
const defaultOptions = {} as const; 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` export const WebhookFragmentDoc = gql`
fragment Webhook on Webhook { fragment Webhook on Webhook {
id id
@ -62,6 +81,7 @@ export const AppListItemFragmentDoc = gql`
type type
appUrl appUrl
manifestUrl manifestUrl
version
permissions { permissions {
...AppPermission ...AppPermission
} }
@ -2921,28 +2941,15 @@ export const AppFetchDocument = gql`
mutation AppFetch($manifestUrl: String!) { mutation AppFetch($manifestUrl: String!) {
appFetchManifest(manifestUrl: $manifestUrl) { appFetchManifest(manifestUrl: $manifestUrl) {
manifest { manifest {
identifier ...AppManifest
version
about
name
appUrl
configurationUrl
tokenTargetUrl
dataPrivacy
dataPrivacyUrl
homepageUrl
supportUrl
permissions {
code
name
}
} }
errors { errors {
...AppError ...AppError
} }
} }
} }
${AppErrorFragmentDoc}`; ${AppManifestFragmentDoc}
${AppErrorFragmentDoc}`;
export type AppFetchMutationFn = Apollo.MutationFunction<Types.AppFetchMutation, Types.AppFetchMutationVariables>; export type AppFetchMutationFn = Apollo.MutationFunction<Types.AppFetchMutation, Types.AppFetchMutationVariables>;
/** /**

View file

@ -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; }>; 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 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 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 }; export type AppPermissionFragment = { __typename: 'Permission', name: string, code: PermissionEnum };

View file

@ -1,10 +1,10 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
export interface PageInfo { export interface PageInfo {
endCursor: string; endCursor: string | null;
hasNextPage: boolean; hasNextPage: boolean;
hasPreviousPage: boolean; hasPreviousPage: boolean;
startCursor: string; startCursor: string | null;
} }
export interface PaginationState { export interface PaginationState {
@ -91,11 +91,14 @@ export function useSectionLocalPaginationState(
function useLocalPaginator( function useLocalPaginator(
setPaginationState: (paginationState: PaginationState) => void, setPaginationState: (paginationState: PaginationState) => void,
) { ) {
function paginate(pageInfo: PageInfo, paginationState: PaginationState) { function paginate(
pageInfo: PageInfo | undefined,
paginationState: PaginationState,
) {
const loadNextPage = () => const loadNextPage = () =>
setPaginationState({ setPaginationState({
...paginationState, ...paginationState,
after: pageInfo.endCursor, after: pageInfo?.endCursor,
before: undefined, before: undefined,
}); });
@ -103,7 +106,7 @@ function useLocalPaginator(
setPaginationState({ setPaginationState({
...paginationState, ...paginationState,
after: undefined, after: undefined,
before: pageInfo.startCursor, before: pageInfo?.startCursor,
}); });
const newPageInfo = pageInfo const newPageInfo = pageInfo

View file

@ -102,8 +102,8 @@ export default usePaginator;
export interface PaginatorContextValuesCommon { export interface PaginatorContextValuesCommon {
hasNextPage?: boolean; hasNextPage?: boolean;
hasPreviousPage?: boolean; hasPreviousPage?: boolean;
endCursor?: string; endCursor?: string | null;
startCursor?: string; startCursor?: string | null;
} }
export type PaginatorContextValues = PaginatorContextValuesCommon & export type PaginatorContextValues = PaginatorContextValuesCommon &

View file

@ -16,6 +16,8 @@
SALEOR_APPS_PAGE_PATH: "<%= SALEOR_APPS_PAGE_PATH %>", SALEOR_APPS_PAGE_PATH: "<%= SALEOR_APPS_PAGE_PATH %>",
SALEOR_APPS_JSON_PATH: "<%= SALEOR_APPS_JSON_PATH %>", SALEOR_APPS_JSON_PATH: "<%= SALEOR_APPS_JSON_PATH %>",
APP_TEMPLATE_GALLERY_PATH: "<%= APP_TEMPLATE_GALLERY_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 %>",
}; };
</script> </script>
</head> </head>

View file

@ -55,6 +55,8 @@ import MarketplaceSection from "./marketplace";
import { marketplaceUrl } from "./marketplace/urls"; import { marketplaceUrl } from "./marketplace/urls";
import NavigationSection from "./navigation"; import NavigationSection from "./navigation";
import { navigationSection } from "./navigation/urls"; import { navigationSection } from "./navigation/urls";
import NewAppsSection from "./new-apps";
import { AppSections } from "./new-apps/urls";
import { NotFound } from "./NotFound"; import { NotFound } from "./NotFound";
import OrdersSection from "./orders"; import OrdersSection from "./orders";
import PageSection from "./pages"; import PageSection from "./pages";
@ -261,6 +263,10 @@ const Routes: React.FC = () => {
path={appsSection} path={appsSection}
component={AppsSection} component={AppsSection}
/> />
<SectionRoute
path={AppSections.appsSection}
component={NewAppsSection}
/>
<SectionRoute <SectionRoute
permissions={[PermissionEnum.MANAGE_APPS]} permissions={[PermissionEnum.MANAGE_APPS]}
path={marketplaceUrl} path={marketplaceUrl}

View file

@ -186,6 +186,10 @@ export const commonMessages = defineMessages({
id: "z9c6/C", id: "z9c6/C",
defaultMessage: "Deprecated", defaultMessage: "Deprecated",
}, },
settings: {
id: "D3idYv",
defaultMessage: "Settings",
},
}); });
export const errorMessages = defineMessages({ export const errorMessages = defineMessages({
@ -333,6 +337,21 @@ export const buttonMessages = defineMessages({
defaultMessage: "Undo", defaultMessage: "Undo",
description: "button", description: "button",
}, },
install: {
id: "KDvRBi",
defaultMessage: "Install",
description: "button",
},
activate: {
id: "+b3KCV",
defaultMessage: "Activate",
description: "button",
},
deactivate: {
id: "gygOA1",
defaultMessage: "Deactivate",
description: "button",
},
}); });
export const sectionNames = defineMessages({ export const sectionNames = defineMessages({

View file

@ -0,0 +1,39 @@
import { Skeleton } from "@material-ui/lab";
import { GetV2SaleorAppsResponse } from "@saleor/new-apps/marketplace.types";
import React from "react";
import AppListCard from "../AppListCard";
import { useStyles } from "./styles";
interface AllAppListProps {
appList?: GetV2SaleorAppsResponse.SaleorApp[];
navigateToAppInstallPage?: (manifestUrl: string) => void;
navigateToVercelDeploymentPage?: (vercelDeploymentUrl: string) => void;
}
const AllAppList: React.FC<AllAppListProps> = ({
appList,
navigateToAppInstallPage,
navigateToVercelDeploymentPage,
}) => {
const classes = useStyles();
if (!appList) {
return <Skeleton />;
}
return (
<div className={classes.appListWrapper}>
{appList.map(app => (
<AppListCard
key={app.name.en}
app={app}
navigateToAppInstallPage={navigateToAppInstallPage}
navigateToVercelDeploymentPage={navigateToVercelDeploymentPage}
/>
))}
</div>
);
};
export default AllAppList;

View file

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

View file

@ -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",
},
);

View file

@ -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", () => <AppActivateDialog {...props} />)
.add("unnamed app", () => <AppActivateDialog {...props} name={null} />);

View file

@ -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(
<Wrapper>
<AppActivateDialog
confirmButtonState="default"
open={true}
name={name}
onClose={jest.fn()}
onConfirm={jest.fn()}
/>
</Wrapper>,
);
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(
<Wrapper>
<AppActivateDialog
confirmButtonState="default"
open={true}
name={""}
onClose={jest.fn()}
onConfirm={jest.fn()}
/>
</Wrapper>,
);
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(
<Wrapper>
<AppActivateDialog
confirmButtonState="default"
open={true}
name={null}
onClose={jest.fn()}
onConfirm={jest.fn()}
/>
</Wrapper>,
);
const dialogContent = screen.getByTestId("dialog-content");
// Assert
const expectedText = msgs.activateApp.defaultMessage;
expect(dialogContent).toHaveTextContent(expectedText);
});
});

View file

@ -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<AppActivateDialogProps> = ({
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: <strong>{getStringOrPlaceholder(name)}</strong>,
});
};
return (
<ActionDialog
confirmButtonLabel={intl.formatMessage(buttonMessages.activate)}
confirmButtonState={confirmButtonState}
open={open}
onClose={onClose}
onConfirm={onConfirm}
title={intl.formatMessage(msgs.activateAppTitle)}
variant="default"
>
<DialogContentText data-test-id="dialog-content">
{getMainText()}
</DialogContentText>
</ActionDialog>
);
};
AppActivateDialog.displayName = "AppActivateDialog";
export default AppActivateDialog;

View file

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

View file

@ -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",
},
});

View file

@ -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", () => <AppDeactivateDialog {...props} />)
.add("unnamed app", () => <AppDeactivateDialog {...props} name={null} />);

View file

@ -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(
<Wrapper>
<AppDeactivateDialog
confirmButtonState="default"
open={true}
name={name}
onClose={jest.fn()}
onConfirm={jest.fn()}
/>
</Wrapper>,
);
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(
<Wrapper>
<AppDeactivateDialog
confirmButtonState="default"
open={true}
name={""}
onClose={jest.fn()}
onConfirm={jest.fn()}
/>
</Wrapper>,
);
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(
<Wrapper>
<AppDeactivateDialog
confirmButtonState="default"
open={true}
name={null}
onClose={jest.fn()}
onConfirm={jest.fn()}
/>
</Wrapper>,
);
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(
<Wrapper>
<AppDeactivateDialog
confirmButtonState="default"
open={true}
name={name}
thirdParty={true}
onClose={jest.fn()}
onConfirm={jest.fn()}
/>
</Wrapper>,
);
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(
<Wrapper>
<AppDeactivateDialog
confirmButtonState="default"
open={true}
name={name}
thirdParty={false}
onClose={jest.fn()}
onConfirm={jest.fn()}
/>
</Wrapper>,
);
const dialogContent = screen.getByTestId("dialog-content");
// Assert
const notExpectedBillingWarning =
msgs.deactivateAppBillingInfo.defaultMessage;
expect(dialogContent).not.toHaveTextContent(notExpectedBillingWarning);
});
});

View file

@ -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<AppDeactivateDialogProps> = ({
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: <strong>{getStringOrPlaceholder(name)}</strong>,
});
};
return (
<ActionDialog
confirmButtonLabel={intl.formatMessage(buttonMessages.deactivate)}
confirmButtonState={confirmButtonState}
open={open}
onClose={onClose}
onConfirm={onConfirm}
title={intl.formatMessage(msgs.deactivateAppTitle)}
variant="delete"
>
<DialogContentText data-test-id="dialog-content">
{getMainText()}
{thirdParty && (
<>
{" "}
<FormattedMessage {...msgs.deactivateAppBillingInfo} />
</>
)}
</DialogContentText>
</ActionDialog>
);
};
AppDeactivateDialog.displayName = "AppDeactivateDialog";
export default AppDeactivateDialog;

View file

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

View file

@ -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",
},
});

View file

@ -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", () => <AppDeleteDialog {...props} />)
.add("unnamed app", () => <AppDeleteDialog {...props} name={null} />);

View file

@ -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(
<Wrapper>
<AppDeleteDialog
confirmButtonState="default"
open={true}
name={name}
type="EXTERNAL"
onClose={jest.fn()}
onConfirm={jest.fn()}
/>
</Wrapper>,
);
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(
<Wrapper>
<AppDeleteDialog
confirmButtonState="default"
open={true}
name={name}
type="CUSTOM"
onClose={jest.fn()}
onConfirm={jest.fn()}
/>
</Wrapper>,
);
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(
<Wrapper>
<AppDeleteDialog
confirmButtonState="default"
open={true}
name={""}
type="EXTERNAL"
onClose={jest.fn()}
onConfirm={jest.fn()}
/>
</Wrapper>,
);
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(
<Wrapper>
<AppDeleteDialog
confirmButtonState="default"
open={true}
name={""}
type="CUSTOM"
onClose={jest.fn()}
onConfirm={jest.fn()}
/>
</Wrapper>,
);
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(
<Wrapper>
<AppDeleteDialog
confirmButtonState="default"
open={true}
name={null}
type="EXTERNAL"
onClose={jest.fn()}
onConfirm={jest.fn()}
/>
</Wrapper>,
);
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(
<Wrapper>
<AppDeleteDialog
confirmButtonState="default"
open={true}
name={null}
type="CUSTOM"
onClose={jest.fn()}
onConfirm={jest.fn()}
/>
</Wrapper>,
);
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);
});
});

View file

@ -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<AppDeleteDialogProps> = ({
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: <strong>{getStringOrPlaceholder(name)}</strong>,
});
}
return intl.formatMessage(msgs.deleteLocalNamedApp, {
name: <strong>{getStringOrPlaceholder(name)}</strong>,
});
};
return (
<ActionDialog
confirmButtonState={confirmButtonState}
open={open}
onClose={onClose}
onConfirm={onConfirm}
title={intl.formatMessage(msgs.deleteAppTitle)}
variant="delete"
>
<DialogContentText data-test-id="dialog-content">
{getMainText()} <FormattedMessage {...msgs.deleteAppQuestion} />
</DialogContentText>
</ActionDialog>
);
};
AppDeleteDialog.displayName = "AppDeleteDialog";
export default AppDeleteDialog;

View file

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

View file

@ -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",
},
});

View file

@ -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(
<Wrapper>
<AppListCard app={releasedApp} />
</Wrapper>,
);
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(
<Wrapper>
<AppListCard
app={releasedApp}
navigateToAppInstallPage={navigateToAppInstallPage}
navigateToVercelDeploymentPage={navigateToVercelDeploymentPage}
/>
</Wrapper>,
);
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(
<Wrapper>
<AppListCard app={comingSoonApp} />
</Wrapper>,
);
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(
<Wrapper>
<AppListCard app={app} />
</Wrapper>,
);
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(
<Wrapper>
<AppListCard app={app} />
</Wrapper>,
);
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();
});
});

View file

@ -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<AppListCardProps> = ({
app,
navigateToAppInstallPage,
navigateToVercelDeploymentPage,
}) => {
const classes = useStyles();
const intl = useIntl();
const details = getAppDetails(
intl,
app,
navigateToAppInstallPage,
navigateToVercelDeploymentPage,
);
return (
<>
<Card className={classes.card}>
<CardContent className={classes.cardContent}>
<AppListCardDescription app={app} />
<AppListCardLinks links={details.links} />
<AppListCardIntegrations app={app} />
</CardContent>
<AppListCardActions
releaseDate={details.releaseDate}
installHandler={details.installHandler}
vercelDeployHandler={details.vercelDeployHandler}
/>
</Card>
</>
);
};
AppListCard.displayName = "AppListCard";
export default AppListCard;

View file

@ -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<AppListCardActionsProps> = ({
releaseDate,
installHandler,
vercelDeployHandler,
}) => {
const classes = useActionsStyles();
if (!installHandler && !vercelDeployHandler && !releaseDate) {
return null;
}
return (
<>
<Hr />
<CardActions className={classes.cardActions}>
{vercelDeployHandler && (
<Button
variant="secondary"
onClick={vercelDeployHandler}
data-test-id="app-deploy-to-vercel-button"
>
<FormattedMessage {...messages.deployToVercel} />
</Button>
)}
{installHandler && (
<Button
variant="primary"
onClick={installHandler}
data-test-id="app-install-button"
>
<FormattedMessage {...buttonMessages.install} />
</Button>
)}
{releaseDate && (
<Typography className={classes.releaseDate}>
<FormattedMessage
{...messages.releaseComingSoon}
values={{
releaseDate,
}}
/>
</Typography>
)}
</CardActions>
</>
);
};
AppListCardActions.displayName = "AppListCardActions";
export default AppListCardActions;

View file

@ -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<AppListCardDescriptionProps> = ({
app,
}) => {
const classes = useDescriptionStyles();
return (
<>
<div className={classes.cardToolbar}>
<div
className={classes.logo}
style={{
backgroundColor: app.logo.color,
}}
data-test-id="app-logo"
>
{app.logo.source && <img src={app.logo.source} alt="App logo" />}
{!app.logo.source && (
<Typography variant="h2" data-test-id="app-logo-placeholder">
{app.name.en[0] || ""}
</Typography>
)}
</div>
<Typography className={classes.cardHeader} variant="h2">
{app.name.en}
</Typography>
</div>
<Typography className={classes.description} variant="body1">
{app.description.en}
</Typography>
</>
);
};
AppListCardDescription.displayName = "AppListCardDescription";
export default AppListCardDescription;

View file

@ -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<AppListCardIntegrationsProps> = ({
app,
}) => {
const { themeType } = useTheme();
const classes = useIntegrationsStyles();
if (!app.integrations.length) {
return null;
}
return (
<>
<Typography className={classes.listHeader} variant="h3">
<FormattedMessage {...messages.integrations} />
</Typography>
<ul className={classes.logoList}>
{app.integrations.map(integration => (
<li className={classes.vendorLogo} key={integration.name}>
<img
title={integration.name}
src={
themeType === "dark"
? integration.logo.dark.source
: integration.logo.light.source
}
alt={integration.name}
/>
</li>
))}
</ul>
</>
);
};
AppListCardIntegrations.displayName = "AppListCardIntegrations";
export default AppListCardIntegrations;

View file

@ -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<AppListCardLinksProps> = ({ links }) => {
const classes = useLinksStyles();
if (!links.length) {
return null;
}
return (
<ul className={classes.linkList}>
{links.map(link => (
<li key={link.name}>
<Typography>
<Link href={link.url} target="_blank">
{link.name}
</Link>
</Typography>
</li>
))}
</ul>
);
};
AppListCardLinks.displayName = "AppListCardLinks";
export default AppListCardLinks;

View file

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

View file

@ -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",
},
});

View file

@ -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" },
);

View file

@ -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", () => <AppListPage {...props} />)
.add("empty", () => (
<AppListPage
{...props}
installedApps={[]}
installableMarketplaceApps={[]}
comingSoonMarketplaceApps={[]}
/>
));

View file

@ -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<AppListPageProps> = 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 (
<Container>
<PageHeader title={intl.formatMessage(sectionNames.apps)}>
<InstallWithManifestFormButton onSubmitted={navigateToAppInstallPage} />
</PageHeader>
<Typography variant="body1">
<FormattedMessage {...messages.installAppDescription} />
</Typography>
<div className={classes.appContent}>
{sectionsAvailability.installed && (
<>
<SectionHeader title={intl.formatMessage(messages.installedApps)} />
<InstalledAppList
appList={verifiedInstalledApps}
disabled={disabled}
settings={settings}
onUpdateListSettings={onUpdateListSettings}
/>
</>
)}
<MarketplaceAlert error={marketplaceError} />
{sectionsAvailability.all && !marketplaceError && (
<>
<SectionHeader title={intl.formatMessage(messages.allApps)} />
<AllAppList
appList={verifiedInstallableMarketplaceApps}
navigateToAppInstallPage={navigateToAppInstallPage}
navigateToVercelDeploymentPage={navigateToVercelDeploymentPage}
/>
</>
)}
{sectionsAvailability.comingSoon && !marketplaceError && (
<>
<SectionHeader
title={intl.formatMessage(messages.comingSoonApps)}
/>
<AllAppList appList={comingSoonMarketplaceApps} />
</>
)}
</div>
</Container>
);
};
AppListPage.displayName = "AppListPage";
export default AppListPage;

View file

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

View file

@ -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",
},
});

View file

@ -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" },
);

View file

@ -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[];
}

View file

@ -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,
);
});
});

View file

@ -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),
);

View file

@ -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 (
<Tooltip
placement="top"
title={manifestUrl}
header={intl.formatMessage(appsMessages.appManifestUrl)}
>
<Typography
onMouseOut={() => 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 && (
<CopyIcon
className={clsx(styles.copyIcon, {
[styles.copyIconColorful]: copied,
})}
/>
)}
</Typography>
</Tooltip>
);
};
AppManifestTableDisplay.displayName = "AppManifestTableDisplay";
export default AppManifestTableDisplay;

View file

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

View file

@ -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" },
);

View file

@ -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<AppPermissionsProps> = ({
permissions,
}) => {
const classes = useStyles();
return (
<Tooltip
header={<FormattedMessage {...messages.appPermissions} />}
title={
<ul className={classes.list}>
{permissions?.map(permission => (
<li key={permission.code}>{permission.name}</li>
))}
</ul>
}
>
<IconButton variant="secondary" color="primary">
<PermissionsIcon />
</IconButton>
</Tooltip>
);
};
AppPermissions.displayName = "AppPermissions";
export default AppPermissions;

View file

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

View file

@ -0,0 +1,9 @@
import { defineMessages } from "react-intl";
export const messages = defineMessages({
appPermissions: {
id: "xNfh4L",
defaultMessage: "App permissions",
description: "app permissions tooltip header",
},
});

View file

@ -0,0 +1,11 @@
import { makeStyles } from "@saleor/macaw-ui";
export const useStyles = makeStyles(
() => ({
list: {
margin: 0,
paddingLeft: "16px",
},
}),
{ name: "AppPermissions" },
);

View file

@ -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(
<Wrapper>
<InstallWithManifestFormButton onSubmitted={submitHandler} />
</Wrapper>,
);
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(
<Wrapper>
<InstallWithManifestFormButton onSubmitted={submitHandler} />
</Wrapper>,
);
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);
});
});

View file

@ -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<Props> = ({
onSubmitted,
}) => {
const styles = useStyles();
const intl = useIntl();
const [inputOpened, setInputOpened] = useState(false);
const handleFormSubmit: React.FormEventHandler<HTMLFormElement> = 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 (
<form onSubmit={handleFormSubmit}>
<TextField
data-test-id="manifest-url-input"
required
type="url"
name="manifest-url"
label={intl.formatMessage(appsMessages.appManifestUrl)}
defaultValue=""
helperText={intl.formatMessage(messages.appManifestUrlHint)}
/>
<Button
size="medium"
type="submit"
className={styles.installButton}
variant="primary"
data-test-id="install-app-from-manifest"
>
<FormattedMessage {...buttonMessages.install} />
</Button>
</form>
);
}
return (
<Button
variant="secondary"
data-test-id="add-app-from-manifest"
onClick={() => setInputOpened(true)}
>
<FormattedMessage {...messages.installExternalApp} />
</Button>
);
};
InstallWithManifestFormButton.displayName = "InstallWithManifestFormButton";
export default InstallWithManifestFormButton;

View file

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

View file

@ -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",
},
});

View file

@ -0,0 +1,13 @@
import { makeStyles } from "@saleor/macaw-ui";
export const useStyles = makeStyles(
theme => ({
installButton: {
marginLeft: theme.spacing(2),
height: 52,
},
}),
{
name: "InstallWithManifestFormButton",
},
);

View file

@ -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<InstalledAppListProps> = ({
appList,
disabled,
settings,
onUpdateListSettings,
}) => {
const classes = useStyles();
if (!appList) {
return <Skeleton />;
}
return (
<Table className={classes.table}>
<TableBody>
{appList.map(({ app, isExternal }) => (
<InstalledAppListRow key={app.id} app={app} isExternal={isExternal} />
))}
</TableBody>
<TableFooter>
<TableRow>
<TablePaginationWithContext
disabled={disabled}
settings={settings}
onUpdateListSettings={onUpdateListSettings}
/>
</TableRow>
</TableFooter>
</Table>
);
};
export default InstalledAppList;

View file

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

View file

@ -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",
},
});

View file

@ -0,0 +1,10 @@
import { makeStyles } from "@saleor/macaw-ui";
export const useStyles = makeStyles(
theme => ({
table: {
borderTop: `1px solid ${theme.palette.divider}`,
},
}),
{ name: "InstalledAppList" },
);

View file

@ -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;
}) => (
<Wrapper>
<Router>
<AppListContext.Provider value={context}>
<InstalledAppListRow {...data} />
</AppListContext.Provider>
</Router>
</Wrapper>
);
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(
<Component
data={{
app: activeApp,
isExternal: false,
}}
context={{
activateApp,
deactivateApp,
removeApp,
}}
/>,
);
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(
<Component
data={{
app: activeApp,
isExternal: true,
}}
context={{
activateApp,
deactivateApp,
removeApp,
}}
/>,
);
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(
<Component
data={{
app: {
...activeApp,
appUrl: `https://example${AppsConfig.tunnelUrlKeywords[0]}`,
manifestUrl: `https://example${AppsConfig.tunnelUrlKeywords[0]}/api/manifest`,
},
isExternal: false,
}}
context={{
activateApp,
deactivateApp,
removeApp,
}}
/>,
);
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(
<Component
data={{
app: activeApp,
isExternal: false,
}}
context={{
activateApp,
deactivateApp,
removeApp,
}}
/>,
);
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(
<Component
data={{
app: inactiveApp,
isExternal: false,
}}
context={{
activateApp,
deactivateApp,
removeApp,
}}
/>,
);
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);
});
});

View file

@ -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<InstalledApp> = ({
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 (
<TableRowLink className={classes.row} href={AppUrls.resolveAppUrl(app.id)}>
<TableCellAvatar
initials={app.name?.[0]?.toUpperCase()}
thumbnail={undefined}
className={clsx(classes.col, classes.colLogo)}
>
<div className={classes.mainContent}>
<Typography variant="body1" className={classes.name}>
{app.name}
</Typography>
<Typography variant="body1" className={classes.version}>
{`v${app.version}`}
</Typography>
{isExternal && (
<Pill
color="warning"
className={classes.externalAppLabel}
label={intl.formatMessage(messages.externalApp)}
data-test-id="app-external-label"
/>
)}
</div>
{app.manifestUrl && (
<AppManifestTableDisplay manifestUrl={app.manifestUrl} />
)}
</TableCellAvatar>
<TableCell className={clsx(classes.col, classes.colActions)}>
<div className={classes.actions}>
{app.manifestUrl && isAppInTunnel(app.manifestUrl) ? (
<Typography
variant="caption"
className={classes.tunnel}
data-test-id="app-tunnel-label"
>
{`(${intl.formatMessage(messages.tunnelDevelopment)})`}
</Typography>
) : null}
<TableButtonWrapper>
<Switch
checked={!!app.isActive}
onChange={handleToggleActive}
data-test-id="app-active-switch"
/>
</TableButtonWrapper>
<AppPermissions permissions={app.permissions} />
<TableButtonWrapper>
<IconButton
variant="secondary"
color="primary"
onClick={() => removeApp(app.id)}
data-test-id="app-remove-button"
>
<DeleteIcon />
</IconButton>
</TableButtonWrapper>
</div>
</TableCell>
</TableRowLink>
);
};
export default InstalledAppListRow;

View file

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

View file

@ -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",
},
});

View file

@ -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" },
);

View file

@ -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<MarketplaceAlertProps> = ({
error,
}) => {
const classes = useStyles();
const intl = useIntl();
if (!error) {
return null;
}
return (
<Alert
variant="warning"
className={classes.alert}
close={true}
title={intl.formatMessage(msgs.marketplaceError)}
>
<Typography>
<FormattedMessage
{...msgs.marketplaceErrorMessage}
values={{
message: <b>{error.message}</b>,
}}
/>
</Typography>
</Alert>
);
};
MarketplaceAlert.displayName = "MarketplaceAlert";
export default MarketplaceAlert;

View file

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

View file

@ -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",
},
});

View file

@ -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" },
);

View file

@ -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<SectionHeaderProps> = ({ title }) => {
const classes = useStyles();
return (
<Typography variant="h3" className={classes.sectionHeader}>
{title}
</Typography>
);
};
SectionHeader.displayName = "SectionHeader";
export default SectionHeader;

View file

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

View file

@ -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" },
);

22
src/new-apps/context.ts Normal file
View file

@ -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;
};

198
src/new-apps/fixtures.ts Normal file
View file

@ -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",
},
},
},
],
};

View file

@ -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") });
});
});

View file

@ -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<Cache>({});
// Used to prevent state update if the component is unmounted
const cancelRequest = useRef<boolean>(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;

31
src/new-apps/index.tsx Normal file
View file

@ -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<RouteComponentProps> = () => {
const qs = parseQs(location.search.substr(1));
const params: AppListUrlQueryParams = qs;
return <AppListView params={params} />;
};
const Apps = () => {
const intl = useIntl();
return (
<>
<WindowTitle title={intl.formatMessage(sectionNames.apps)} />
<Switch>
<Route exact path={AppPaths.appListPath} component={AppList} />
</Switch>
</>
);
};
export default Apps;

View file

@ -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;
}

24
src/new-apps/messages.ts Normal file
View file

@ -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",
},
});

View file

@ -0,0 +1,6 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"strictNullChecks": true
}
}

Some files were not shown because too many files have changed in this diff Show more